Upload folder using huggingface_hub
Browse filesThis view is limited to 50 files because it contains too many changes.
See raw diff
- .dockerignore +70 -0
- .editorconfig +42 -0
- .env.example +230 -0
- .flake8 +13 -0
- .gitattributes +1 -0
- .github/workflows/docker-publish.yml +81 -0
- .github/workflows/update-version.yml +51 -0
- .gitignore +98 -0
- .pre-commit-config.yaml +33 -0
- .python-version +1 -0
- CONTRIBUTING.md +169 -0
- Dockerfile +34 -0
- LICENSE +83 -0
- Makefile +64 -0
- README.md +11 -6
- config.py +441 -0
- darwin-install.sh +55 -0
- docker-compose.yml +85 -0
- docs/README_EN.md +958 -0
- docs/qq群.jpg +3 -0
- front/common.js +0 -0
- front/control_panel.html +2092 -0
- front/control_panel_mobile.html +1822 -0
- install.ps1 +35 -0
- install.sh +302 -0
- log.py +179 -0
- pyproject.toml +102 -0
- requirements-dev.txt +20 -0
- requirements-termux.txt +12 -0
- requirements.txt +12 -0
- runtime.txt +1 -0
- setup-dev.sh +93 -0
- src/api/Response_example.txt +210 -0
- src/api/antigravity.py +694 -0
- src/api/geminicli.py +597 -0
- src/api/utils.py +497 -0
- src/auth.py +1242 -0
- src/converter/anthropic2gemini.py +931 -0
- src/converter/anti_truncation.py +699 -0
- src/converter/fake_stream.py +537 -0
- src/converter/gemini_fix.py +418 -0
- src/converter/openai2gemini.py +930 -0
- src/converter/thoughtSignature_fix.py +56 -0
- src/converter/utils.py +231 -0
- src/credential_manager.py +521 -0
- src/google_oauth_api.py +781 -0
- src/httpx_client.py +108 -0
- src/models.py +376 -0
- src/router/antigravity/anthropic.py +566 -0
- src/router/antigravity/gemini.py +690 -0
.dockerignore
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Git files
|
| 2 |
+
.git
|
| 3 |
+
.gitignore
|
| 4 |
+
.github
|
| 5 |
+
|
| 6 |
+
# Python cache
|
| 7 |
+
__pycache__
|
| 8 |
+
*.py[cod]
|
| 9 |
+
*$py.class
|
| 10 |
+
*.so
|
| 11 |
+
.Python
|
| 12 |
+
*.egg-info/
|
| 13 |
+
dist/
|
| 14 |
+
build/
|
| 15 |
+
|
| 16 |
+
# Virtual environments
|
| 17 |
+
venv/
|
| 18 |
+
env/
|
| 19 |
+
ENV/
|
| 20 |
+
.venv
|
| 21 |
+
|
| 22 |
+
# IDE
|
| 23 |
+
.vscode/
|
| 24 |
+
.idea/
|
| 25 |
+
*.swp
|
| 26 |
+
*.swo
|
| 27 |
+
|
| 28 |
+
# OS
|
| 29 |
+
.DS_Store
|
| 30 |
+
Thumbs.db
|
| 31 |
+
|
| 32 |
+
# Documentation
|
| 33 |
+
*.md
|
| 34 |
+
!README.md
|
| 35 |
+
CONTRIBUTING.md
|
| 36 |
+
CHANGELOG.md
|
| 37 |
+
SECURITY.md
|
| 38 |
+
|
| 39 |
+
# Test files
|
| 40 |
+
test_*.py
|
| 41 |
+
tests/
|
| 42 |
+
.pytest_cache/
|
| 43 |
+
.coverage
|
| 44 |
+
htmlcov/
|
| 45 |
+
*.coverage
|
| 46 |
+
|
| 47 |
+
# Development files
|
| 48 |
+
.editorconfig
|
| 49 |
+
.pre-commit-config.yaml
|
| 50 |
+
Makefile
|
| 51 |
+
setup-dev.sh
|
| 52 |
+
requirements-dev.txt
|
| 53 |
+
|
| 54 |
+
# Logs
|
| 55 |
+
*.log
|
| 56 |
+
log.txt
|
| 57 |
+
|
| 58 |
+
# Credentials (never include)
|
| 59 |
+
creds/
|
| 60 |
+
*.json
|
| 61 |
+
!package.json
|
| 62 |
+
*.toml
|
| 63 |
+
!pyproject.toml
|
| 64 |
+
.env
|
| 65 |
+
.env.*
|
| 66 |
+
|
| 67 |
+
# Temporary files
|
| 68 |
+
*.tmp
|
| 69 |
+
*.bak
|
| 70 |
+
tmp/
|
.editorconfig
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# EditorConfig helps maintain consistent coding styles across editors
|
| 2 |
+
# https://editorconfig.org
|
| 3 |
+
|
| 4 |
+
root = true
|
| 5 |
+
|
| 6 |
+
[*]
|
| 7 |
+
charset = utf-8
|
| 8 |
+
end_of_line = lf
|
| 9 |
+
insert_final_newline = true
|
| 10 |
+
trim_trailing_whitespace = true
|
| 11 |
+
|
| 12 |
+
[*.{py,pyi}]
|
| 13 |
+
indent_style = space
|
| 14 |
+
indent_size = 4
|
| 15 |
+
max_line_length = 100
|
| 16 |
+
|
| 17 |
+
[*.{yml,yaml}]
|
| 18 |
+
indent_style = space
|
| 19 |
+
indent_size = 2
|
| 20 |
+
|
| 21 |
+
[*.{json,toml}]
|
| 22 |
+
indent_style = space
|
| 23 |
+
indent_size = 2
|
| 24 |
+
|
| 25 |
+
[*.{md,markdown}]
|
| 26 |
+
trim_trailing_whitespace = false
|
| 27 |
+
max_line_length = off
|
| 28 |
+
|
| 29 |
+
[*.{sh,bat,ps1}]
|
| 30 |
+
indent_style = space
|
| 31 |
+
indent_size = 2
|
| 32 |
+
|
| 33 |
+
[Makefile]
|
| 34 |
+
indent_style = tab
|
| 35 |
+
|
| 36 |
+
[*.js]
|
| 37 |
+
indent_style = space
|
| 38 |
+
indent_size = 2
|
| 39 |
+
|
| 40 |
+
[*.html]
|
| 41 |
+
indent_style = space
|
| 42 |
+
indent_size = 2
|
.env.example
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
# 存储后端优先级: MongoDB > 本地sqlite文件存储
|
| 41 |
+
# 系统会自动选择可用的最高优先级存储后端
|
| 42 |
+
|
| 43 |
+
# MongoDB 分布式存储模式配置 (第二优先级)
|
| 44 |
+
# 设置 MONGODB_URI 后自动启用 MongoDB 模式,不再使用本地文件存储
|
| 45 |
+
|
| 46 |
+
# MongoDB 连接字符串 (设置后启用 MongoDB 分布式存储模式)
|
| 47 |
+
# 本地 MongoDB: mongodb://localhost:27017
|
| 48 |
+
# 带认证: mongodb://admin:password@localhost:27017/admin
|
| 49 |
+
# MongoDB Atlas: mongodb+srv://username:password@cluster.mongodb.net
|
| 50 |
+
# 副本集: mongodb://host1:27017,host2:27017,host3:27017/gcli2api?replicaSet=rs0
|
| 51 |
+
# 默认: 无 (使用本地文件存储)
|
| 52 |
+
MONGODB_URI=mongodb://localhost:27017
|
| 53 |
+
|
| 54 |
+
# MongoDB 数据库名称 (仅在启用 MongoDB 模式时有效)
|
| 55 |
+
# 默认: gcli2api
|
| 56 |
+
MONGODB_DATABASE=gcli2api
|
| 57 |
+
|
| 58 |
+
# ================================================================
|
| 59 |
+
# Google API 配置
|
| 60 |
+
# ================================================================
|
| 61 |
+
|
| 62 |
+
# 凭证文件目录 (仅在文件存储模式下使用)
|
| 63 |
+
# 默认: ./creds
|
| 64 |
+
CREDENTIALS_DIR=./creds
|
| 65 |
+
|
| 66 |
+
# 是否自动从环境变量加载凭证
|
| 67 |
+
# 默认: false
|
| 68 |
+
AUTO_LOAD_ENV_CREDS=false
|
| 69 |
+
|
| 70 |
+
# Google 凭证环境变量配置 (可选,通过 GCLI_CREDS_* 环境变量提供凭证)
|
| 71 |
+
# 支持编号格式和项目名格式
|
| 72 |
+
# GCLI_CREDS_1={"client_id":"your-client-id","client_secret":"your-secret","refresh_token":"your-token","token_uri":"https://oauth2.googleapis.com/token","project_id":"your-project"}
|
| 73 |
+
# GCLI_CREDS_2={"client_id":"...","project_id":"..."}
|
| 74 |
+
# GCLI_CREDS_myproject={"client_id":"...","project_id":"myproject",...}
|
| 75 |
+
|
| 76 |
+
# ================================================================
|
| 77 |
+
# 凭证轮换配置
|
| 78 |
+
# ================================================================
|
| 79 |
+
|
| 80 |
+
# 每个凭证使用多少次调用后轮换到下一个
|
| 81 |
+
# 默认: 100
|
| 82 |
+
CALLS_PER_ROTATION=100
|
| 83 |
+
|
| 84 |
+
# 代理配置 (可选)
|
| 85 |
+
# 支持 http, https, socks5 代理
|
| 86 |
+
# 格式: http://proxy:port, https://proxy:port, socks5://proxy:port
|
| 87 |
+
PROXY=http://localhost:7890
|
| 88 |
+
|
| 89 |
+
# Google API 代理 URL 配置 (可选)
|
| 90 |
+
|
| 91 |
+
# Google Code Assist API 端点
|
| 92 |
+
# 默认: https://cloudcode-pa.googleapis.com
|
| 93 |
+
CODE_ASSIST_ENDPOINT=https://cloudcode-pa.googleapis.com
|
| 94 |
+
# 用于Google OAuth2认证的代理URL
|
| 95 |
+
# 默认: https://oauth2.googleapis.com
|
| 96 |
+
OAUTH_PROXY_URL=https://oauth2.googleapis.com
|
| 97 |
+
|
| 98 |
+
# 用于Google APIs调用的代理URL
|
| 99 |
+
# 默认: https://www.googleapis.com
|
| 100 |
+
GOOGLEAPIS_PROXY_URL=https://www.googleapis.com
|
| 101 |
+
|
| 102 |
+
# 用于Google Cloud Resource Manager API的URL
|
| 103 |
+
# 默认: https://cloudresourcemanager.googleapis.com
|
| 104 |
+
RESOURCE_MANAGER_API_URL=https://cloudresourcemanager.googleapis.com
|
| 105 |
+
|
| 106 |
+
# 用于Google Cloud Service Usage API的URL
|
| 107 |
+
# 默认: https://serviceusage.googleapis.com
|
| 108 |
+
SERVICE_USAGE_API_URL=https://serviceusage.googleapis.com
|
| 109 |
+
|
| 110 |
+
# 用于Google Antigravity API的URL (反重力模式)
|
| 111 |
+
# 默认: https://daily-cloudcode-pa.sandbox.googleapis.com
|
| 112 |
+
ANTIGRAVITY_API_URL=https://daily-cloudcode-pa.sandbox.googleapis.com
|
| 113 |
+
|
| 114 |
+
# ================================================================
|
| 115 |
+
# 错误处理和重试配置
|
| 116 |
+
# ================================================================
|
| 117 |
+
|
| 118 |
+
# 是否启用自动封禁功能
|
| 119 |
+
# 当凭证返回特定错误码时自动禁用该凭证
|
| 120 |
+
# 默认: false
|
| 121 |
+
AUTO_BAN=false
|
| 122 |
+
|
| 123 |
+
# 自动封禁的错误码列表 (逗号分隔)
|
| 124 |
+
# 默认: 400,403
|
| 125 |
+
AUTO_BAN_ERROR_CODES=400,403
|
| 126 |
+
|
| 127 |
+
# 是否启用 429 错误重试
|
| 128 |
+
# 默认: true
|
| 129 |
+
RETRY_429_ENABLED=true
|
| 130 |
+
|
| 131 |
+
# 429 错误最大重试次数
|
| 132 |
+
# 默认: 5
|
| 133 |
+
RETRY_429_MAX_RETRIES=5
|
| 134 |
+
|
| 135 |
+
# 429 错误重试间隔 (秒)
|
| 136 |
+
# 默认: 1
|
| 137 |
+
RETRY_429_INTERVAL=1
|
| 138 |
+
|
| 139 |
+
# ================================================================
|
| 140 |
+
# 日志配置
|
| 141 |
+
# ================================================================
|
| 142 |
+
|
| 143 |
+
# 日志级别
|
| 144 |
+
# 可选值: debug, info, warning, error, critical
|
| 145 |
+
# 默认: info
|
| 146 |
+
LOG_LEVEL=info
|
| 147 |
+
|
| 148 |
+
# 日志文件路径
|
| 149 |
+
# 默认: log.txt
|
| 150 |
+
LOG_FILE=log.txt
|
| 151 |
+
|
| 152 |
+
# ================================================================
|
| 153 |
+
# 高级功能配置
|
| 154 |
+
# ================================================================
|
| 155 |
+
|
| 156 |
+
# 流式抗截断最大尝试次数
|
| 157 |
+
# 用于 "流式抗截断/" 前缀的模型
|
| 158 |
+
# 默认: 3
|
| 159 |
+
ANTI_TRUNCATION_MAX_ATTEMPTS=3
|
| 160 |
+
|
| 161 |
+
# ================================================================
|
| 162 |
+
# 环境变量使用说明
|
| 163 |
+
# ================================================================
|
| 164 |
+
|
| 165 |
+
# 1. 存储模式配置 (按优先级自动选择):
|
| 166 |
+
# - Redis 分布式模式 (最高优先级): 设置 REDIS_URI,数据存储在 Redis 数据库,性能最佳
|
| 167 |
+
# - MongoDB 分布式模式 (第二优先级): 设置 MONGODB_URI,数据存储在 MongoDB 数据库
|
| 168 |
+
# - 文件存储模式 (默认): 不设置上述 URI,数据存储在本地 creds/ 目录
|
| 169 |
+
# - 自动切换: 系统根据可用的存储配置自动选择最高优先级的存储后端
|
| 170 |
+
|
| 171 |
+
# 2. 凭证配置方式 (三选一):
|
| 172 |
+
# a) 将 Google 凭证 JSON 文件放在 CREDENTIALS_DIR 目录中 (仅文件模式)
|
| 173 |
+
# b) 设置 AUTO_LOAD_ENV_CREDS=true,通过 GOOGLE_CREDENTIALS 等环境变量直接提供
|
| 174 |
+
# c) 通过面板导入
|
| 175 |
+
|
| 176 |
+
# 3. 密码配置优先级:
|
| 177 |
+
# a) PASSWORD 环境变量 (最高优先级,设置后覆盖其他密码)
|
| 178 |
+
# b) API_PASSWORD / PANEL_PASSWORD 环境变量 (专用密码)
|
| 179 |
+
# c) config.toml 文件中的密码配置
|
| 180 |
+
# d) 默认值 "pwd"
|
| 181 |
+
#
|
| 182 |
+
# 4. 通用配置优先级:
|
| 183 |
+
# 环境变量 > config.toml 文件 > 默认值
|
| 184 |
+
|
| 185 |
+
# 5. 布尔值环境变量:
|
| 186 |
+
# true/1/yes/on 表示启用
|
| 187 |
+
# false/0/no/off 表示禁用
|
| 188 |
+
|
| 189 |
+
# 6. 模型功能说明:
|
| 190 |
+
# - 基础模型: gemini-2.5-pro, gemini-2.5-flash 等
|
| 191 |
+
# - 功能前缀:
|
| 192 |
+
# * "假流式/" - 使用假流式传输
|
| 193 |
+
# * "流式抗截断/" - 启用流式抗截断功能
|
| 194 |
+
# - 功能后缀:
|
| 195 |
+
# * "-maxthinking" - 最大思考预算
|
| 196 |
+
# * "-nothinking" - 禁用思考模式
|
| 197 |
+
# * "-search" - 启用 Google 搜索
|
| 198 |
+
|
| 199 |
+
# 7. 示例模型名称:
|
| 200 |
+
# - gemini-2.5-pro
|
| 201 |
+
# - 假流式/gemini-2.5-pro-maxthinking
|
| 202 |
+
# - 流式抗截断/gemini-2.5-flash-search
|
| 203 |
+
|
| 204 |
+
# ================================================================
|
| 205 |
+
# 配置文件说明
|
| 206 |
+
# ================================================================
|
| 207 |
+
|
| 208 |
+
# 除了环境变量,你还可以使用 TOML 配置文件进行配置
|
| 209 |
+
# 配置文件位置: {CREDENTIALS_DIR}/config.toml
|
| 210 |
+
#
|
| 211 |
+
# # 密码配置
|
| 212 |
+
# api_password = "your_api_password" # 聊天API密码
|
| 213 |
+
# panel_password = "your_panel_password" # 控制面板密码
|
| 214 |
+
# password = "your_common_password" # 通用密码 (覆盖上述两个)
|
| 215 |
+
#
|
| 216 |
+
# # 基础配置
|
| 217 |
+
# calls_per_rotation = 100
|
| 218 |
+
#
|
| 219 |
+
# [retry]
|
| 220 |
+
# retry_429_enabled = true
|
| 221 |
+
# retry_429_max_retries = 5
|
| 222 |
+
# retry_429_interval = 1
|
| 223 |
+
#
|
| 224 |
+
# [logging]
|
| 225 |
+
# log_level = "info"
|
| 226 |
+
# log_file = "log.txt"
|
| 227 |
+
#
|
| 228 |
+
# [auto_ban]
|
| 229 |
+
# auto_ban_enabled = false
|
| 230 |
+
# auto_ban_error_codes = [400, 403]
|
.flake8
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[flake8]
|
| 2 |
+
max-line-length = 100
|
| 3 |
+
extend-ignore = E203, W503, E501
|
| 4 |
+
exclude =
|
| 5 |
+
.git,
|
| 6 |
+
__pycache__,
|
| 7 |
+
.venv,
|
| 8 |
+
venv,
|
| 9 |
+
gcli,
|
| 10 |
+
build,
|
| 11 |
+
dist,
|
| 12 |
+
.eggs,
|
| 13 |
+
*.egg-info
|
.gitattributes
CHANGED
|
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
docs/qq群.jpg filter=lfs diff=lfs merge=lfs -text
|
.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,98 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
__pycache__/
|
| 18 |
+
*.py[cod]
|
| 19 |
+
*$py.class
|
| 20 |
+
*.so
|
| 21 |
+
.Python
|
| 22 |
+
build/
|
| 23 |
+
develop-eggs/
|
| 24 |
+
dist/
|
| 25 |
+
downloads/
|
| 26 |
+
eggs/
|
| 27 |
+
.eggs/
|
| 28 |
+
lib/
|
| 29 |
+
lib64/
|
| 30 |
+
parts/
|
| 31 |
+
sdist/
|
| 32 |
+
var/
|
| 33 |
+
wheels/
|
| 34 |
+
pip-wheel-metadata/
|
| 35 |
+
share/python-wheels/
|
| 36 |
+
*.egg-info/
|
| 37 |
+
.installed.cfg
|
| 38 |
+
*.egg
|
| 39 |
+
MANIFEST
|
| 40 |
+
|
| 41 |
+
# PyInstaller
|
| 42 |
+
*.manifest
|
| 43 |
+
*.spec
|
| 44 |
+
|
| 45 |
+
# Installer logs
|
| 46 |
+
pip-log.txt
|
| 47 |
+
pip-delete-this-directory.txt
|
| 48 |
+
|
| 49 |
+
# Unit test / coverage reports
|
| 50 |
+
htmlcov/
|
| 51 |
+
.tox/
|
| 52 |
+
.nox/
|
| 53 |
+
.coverage
|
| 54 |
+
.coverage.*
|
| 55 |
+
.cache
|
| 56 |
+
nosetests.xml
|
| 57 |
+
coverage.xml
|
| 58 |
+
*.cover
|
| 59 |
+
*.py,cover
|
| 60 |
+
.hypothesis/
|
| 61 |
+
.pytest_cache/
|
| 62 |
+
|
| 63 |
+
# Virtual environments
|
| 64 |
+
.env
|
| 65 |
+
.venv
|
| 66 |
+
env/
|
| 67 |
+
venv/
|
| 68 |
+
ENV/
|
| 69 |
+
env.bak/
|
| 70 |
+
venv.bak/
|
| 71 |
+
|
| 72 |
+
# IDE
|
| 73 |
+
.vscode/
|
| 74 |
+
.idea/
|
| 75 |
+
.claude/
|
| 76 |
+
*.swp
|
| 77 |
+
*.swo
|
| 78 |
+
*~
|
| 79 |
+
|
| 80 |
+
# OS
|
| 81 |
+
.DS_Store
|
| 82 |
+
.DS_Store?
|
| 83 |
+
._*
|
| 84 |
+
.Spotlight-V100
|
| 85 |
+
.Trashes
|
| 86 |
+
ehthumbs.db
|
| 87 |
+
Thumbs.db
|
| 88 |
+
|
| 89 |
+
# Logs
|
| 90 |
+
*.log
|
| 91 |
+
log.txt
|
| 92 |
+
|
| 93 |
+
# Temporary files
|
| 94 |
+
*.tmp
|
| 95 |
+
*.temp
|
| 96 |
+
*.bak
|
| 97 |
+
|
| 98 |
+
tools/
|
.pre-commit-config.yaml
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
repos:
|
| 2 |
+
- repo: https://github.com/pre-commit/pre-commit-hooks
|
| 3 |
+
rev: v4.5.0
|
| 4 |
+
hooks:
|
| 5 |
+
- id: trailing-whitespace
|
| 6 |
+
- id: end-of-file-fixer
|
| 7 |
+
- id: check-yaml
|
| 8 |
+
- id: check-added-large-files
|
| 9 |
+
args: ['--maxkb=1000']
|
| 10 |
+
- id: check-json
|
| 11 |
+
- id: check-toml
|
| 12 |
+
- id: check-merge-conflict
|
| 13 |
+
- id: detect-private-key
|
| 14 |
+
|
| 15 |
+
- repo: https://github.com/psf/black
|
| 16 |
+
rev: 24.1.1
|
| 17 |
+
hooks:
|
| 18 |
+
- id: black
|
| 19 |
+
args: [--line-length=100]
|
| 20 |
+
language_version: python3.12
|
| 21 |
+
|
| 22 |
+
- repo: https://github.com/pycqa/flake8
|
| 23 |
+
rev: 7.0.0
|
| 24 |
+
hooks:
|
| 25 |
+
- id: flake8
|
| 26 |
+
args: [--max-line-length=100, --extend-ignore=E203,W503]
|
| 27 |
+
additional_dependencies: [flake8-docstrings]
|
| 28 |
+
|
| 29 |
+
- repo: https://github.com/pycqa/isort
|
| 30 |
+
rev: 5.13.2
|
| 31 |
+
hooks:
|
| 32 |
+
- id: isort
|
| 33 |
+
args: [--profile=black, --line-length=100]
|
.python-version
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
3.12
|
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.
|
Makefile
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.PHONY: help install install-dev test lint format clean run docker-build docker-run docker-compose-up docker-compose-down
|
| 2 |
+
|
| 3 |
+
help:
|
| 4 |
+
@echo "gcli2api - Development Commands"
|
| 5 |
+
@echo ""
|
| 6 |
+
@echo "Available commands:"
|
| 7 |
+
@echo " make install - Install production dependencies"
|
| 8 |
+
@echo " make install-dev - Install development dependencies"
|
| 9 |
+
@echo " make test - Run tests"
|
| 10 |
+
@echo " make test-cov - Run tests with coverage report"
|
| 11 |
+
@echo " make lint - Run linters (flake8, mypy)"
|
| 12 |
+
@echo " make format - Format code with black"
|
| 13 |
+
@echo " make format-check - Check code formatting without making changes"
|
| 14 |
+
@echo " make clean - Clean build artifacts and cache"
|
| 15 |
+
@echo " make run - Run the application"
|
| 16 |
+
@echo " make docker-build - Build Docker image"
|
| 17 |
+
@echo " make docker-run - Run Docker container"
|
| 18 |
+
@echo " make docker-compose-up - Start services with docker-compose"
|
| 19 |
+
@echo " make docker-compose-down - Stop services with docker-compose"
|
| 20 |
+
|
| 21 |
+
install:
|
| 22 |
+
pip install -r requirements.txt
|
| 23 |
+
|
| 24 |
+
install-dev:
|
| 25 |
+
pip install -e ".[dev]"
|
| 26 |
+
pip install -r requirements-dev.txt
|
| 27 |
+
|
| 28 |
+
test:
|
| 29 |
+
python -m pytest -v
|
| 30 |
+
|
| 31 |
+
test-cov:
|
| 32 |
+
python -m pytest --cov=src --cov-report=term-missing --cov-report=html
|
| 33 |
+
|
| 34 |
+
lint:
|
| 35 |
+
python -m flake8 src/ web.py config.py log.py --max-line-length=100 --extend-ignore=E203,W503
|
| 36 |
+
python -m mypy src/ --ignore-missing-imports
|
| 37 |
+
|
| 38 |
+
format:
|
| 39 |
+
python -m black src/ web.py config.py log.py test_*.py
|
| 40 |
+
|
| 41 |
+
format-check:
|
| 42 |
+
python -m black --check src/ web.py config.py log.py test_*.py
|
| 43 |
+
|
| 44 |
+
clean:
|
| 45 |
+
find . -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true
|
| 46 |
+
find . -type f -name "*.pyc" -delete
|
| 47 |
+
find . -type f -name "*.pyo" -delete
|
| 48 |
+
find . -type f -name "*.log" -delete
|
| 49 |
+
rm -rf .pytest_cache .mypy_cache .coverage htmlcov/ build/ dist/ *.egg-info
|
| 50 |
+
|
| 51 |
+
run:
|
| 52 |
+
python web.py
|
| 53 |
+
|
| 54 |
+
docker-build:
|
| 55 |
+
docker build -t gcli2api:latest .
|
| 56 |
+
|
| 57 |
+
docker-run:
|
| 58 |
+
docker run -d --name gcli2api --network host -e PASSWORD=pwd -e PORT=7861 -v $$(pwd)/data/creds:/app/creds gcli2api:latest
|
| 59 |
+
|
| 60 |
+
docker-compose-up:
|
| 61 |
+
docker-compose up -d
|
| 62 |
+
|
| 63 |
+
docker-compose-down:
|
| 64 |
+
docker-compose down
|
README.md
CHANGED
|
@@ -1,10 +1,15 @@
|
|
| 1 |
---
|
| 2 |
-
title: 2api
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
| 7 |
-
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: "2api"
|
| 3 |
+
emoji: "🚀"
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: green
|
| 6 |
sdk: docker
|
| 7 |
+
app_port: 7861
|
| 8 |
---
|
| 9 |
|
| 10 |
+
### 🚀 一键部署
|
| 11 |
+
[](https://github.com/kfcx/HFSpaceDeploy)
|
| 12 |
+
|
| 13 |
+
本项目由[HFSpaceDeploy](https://github.com/kfcx/HFSpaceDeploy)一键部署
|
| 14 |
+
|
| 15 |
+
|
config.py
ADDED
|
@@ -0,0 +1,441 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
"ANTIGRAVITY_API_URL": "antigravity_api_url",
|
| 33 |
+
"AUTO_BAN": "auto_ban_enabled",
|
| 34 |
+
"AUTO_BAN_ERROR_CODES": "auto_ban_error_codes",
|
| 35 |
+
"RETRY_429_MAX_RETRIES": "retry_429_max_retries",
|
| 36 |
+
"RETRY_429_ENABLED": "retry_429_enabled",
|
| 37 |
+
"RETRY_429_INTERVAL": "retry_429_interval",
|
| 38 |
+
"ANTI_TRUNCATION_MAX_ATTEMPTS": "anti_truncation_max_attempts",
|
| 39 |
+
"COMPATIBILITY_MODE": "compatibility_mode_enabled",
|
| 40 |
+
"RETURN_THOUGHTS_TO_FRONTEND": "return_thoughts_to_frontend",
|
| 41 |
+
"ANTIGRAVITY_STREAM2NOSTREAM": "antigravity_stream2nostream",
|
| 42 |
+
"HOST": "host",
|
| 43 |
+
"PORT": "port",
|
| 44 |
+
"API_PASSWORD": "api_password",
|
| 45 |
+
"PANEL_PASSWORD": "panel_password",
|
| 46 |
+
"PASSWORD": "password",
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
# ====================== 配置系统 ======================
|
| 51 |
+
|
| 52 |
+
async def init_config():
|
| 53 |
+
"""初始化配置缓存(启动时调用一次)"""
|
| 54 |
+
global _config_cache, _config_initialized
|
| 55 |
+
|
| 56 |
+
if _config_initialized:
|
| 57 |
+
return
|
| 58 |
+
|
| 59 |
+
try:
|
| 60 |
+
from src.storage_adapter import get_storage_adapter
|
| 61 |
+
storage_adapter = await get_storage_adapter()
|
| 62 |
+
_config_cache = await storage_adapter.get_all_config()
|
| 63 |
+
_config_initialized = True
|
| 64 |
+
except Exception:
|
| 65 |
+
# 初始化失败时使用空缓存
|
| 66 |
+
_config_cache = {}
|
| 67 |
+
_config_initialized = True
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
async def reload_config():
|
| 71 |
+
"""重新加载配置(修改配置后调用)"""
|
| 72 |
+
global _config_cache, _config_initialized
|
| 73 |
+
|
| 74 |
+
try:
|
| 75 |
+
from src.storage_adapter import get_storage_adapter
|
| 76 |
+
storage_adapter = await get_storage_adapter()
|
| 77 |
+
|
| 78 |
+
# 如果后端支持 reload_config_cache,调用它
|
| 79 |
+
if hasattr(storage_adapter._backend, 'reload_config_cache'):
|
| 80 |
+
await storage_adapter._backend.reload_config_cache()
|
| 81 |
+
|
| 82 |
+
# 重新加载配置缓存
|
| 83 |
+
_config_cache = await storage_adapter.get_all_config()
|
| 84 |
+
_config_initialized = True
|
| 85 |
+
except Exception:
|
| 86 |
+
pass
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def _get_cached_config(key: str, default: Any = None) -> Any:
|
| 90 |
+
"""从内存缓存获取配置(同步)"""
|
| 91 |
+
return _config_cache.get(key, default)
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
async def get_config_value(key: str, default: Any = None, env_var: Optional[str] = None) -> Any:
|
| 95 |
+
"""Get configuration value with priority: ENV > Storage > default."""
|
| 96 |
+
# 确保配置已初始化
|
| 97 |
+
if not _config_initialized:
|
| 98 |
+
await init_config()
|
| 99 |
+
|
| 100 |
+
# Priority 1: Environment variable
|
| 101 |
+
if env_var and os.getenv(env_var):
|
| 102 |
+
return os.getenv(env_var)
|
| 103 |
+
|
| 104 |
+
# Priority 2: Memory cache
|
| 105 |
+
value = _get_cached_config(key)
|
| 106 |
+
if value is not None:
|
| 107 |
+
return value
|
| 108 |
+
|
| 109 |
+
return default
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
# Configuration getters - all async
|
| 113 |
+
async def get_proxy_config():
|
| 114 |
+
"""Get proxy configuration."""
|
| 115 |
+
proxy_url = await get_config_value("proxy", env_var="PROXY")
|
| 116 |
+
return proxy_url if proxy_url else None
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
async def get_auto_ban_enabled() -> bool:
|
| 120 |
+
"""Get auto ban enabled setting."""
|
| 121 |
+
env_value = os.getenv("AUTO_BAN")
|
| 122 |
+
if env_value:
|
| 123 |
+
return env_value.lower() in ("true", "1", "yes", "on")
|
| 124 |
+
|
| 125 |
+
return bool(await get_config_value("auto_ban_enabled", False))
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
async def get_auto_ban_error_codes() -> list:
|
| 129 |
+
"""
|
| 130 |
+
Get auto ban error codes.
|
| 131 |
+
|
| 132 |
+
Environment variable: AUTO_BAN_ERROR_CODES (comma-separated, e.g., "400,403")
|
| 133 |
+
Database config key: auto_ban_error_codes
|
| 134 |
+
Default: [400, 403]
|
| 135 |
+
"""
|
| 136 |
+
env_value = os.getenv("AUTO_BAN_ERROR_CODES")
|
| 137 |
+
if env_value:
|
| 138 |
+
try:
|
| 139 |
+
return [int(code.strip()) for code in env_value.split(",") if code.strip()]
|
| 140 |
+
except ValueError:
|
| 141 |
+
pass
|
| 142 |
+
|
| 143 |
+
codes = await get_config_value("auto_ban_error_codes")
|
| 144 |
+
if codes and isinstance(codes, list):
|
| 145 |
+
return codes
|
| 146 |
+
return AUTO_BAN_ERROR_CODES
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
async def get_retry_429_max_retries() -> int:
|
| 150 |
+
"""Get max retries for 429 errors."""
|
| 151 |
+
env_value = os.getenv("RETRY_429_MAX_RETRIES")
|
| 152 |
+
if env_value:
|
| 153 |
+
try:
|
| 154 |
+
return int(env_value)
|
| 155 |
+
except ValueError:
|
| 156 |
+
pass
|
| 157 |
+
|
| 158 |
+
return int(await get_config_value("retry_429_max_retries", 5))
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
async def get_retry_429_enabled() -> bool:
|
| 162 |
+
"""Get 429 retry enabled setting."""
|
| 163 |
+
env_value = os.getenv("RETRY_429_ENABLED")
|
| 164 |
+
if env_value:
|
| 165 |
+
return env_value.lower() in ("true", "1", "yes", "on")
|
| 166 |
+
|
| 167 |
+
return bool(await get_config_value("retry_429_enabled", True))
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
async def get_retry_429_interval() -> float:
|
| 171 |
+
"""Get 429 retry interval in seconds."""
|
| 172 |
+
env_value = os.getenv("RETRY_429_INTERVAL")
|
| 173 |
+
if env_value:
|
| 174 |
+
try:
|
| 175 |
+
return float(env_value)
|
| 176 |
+
except ValueError:
|
| 177 |
+
pass
|
| 178 |
+
|
| 179 |
+
return float(await get_config_value("retry_429_interval", 0.1))
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
async def get_anti_truncation_max_attempts() -> int:
|
| 183 |
+
"""
|
| 184 |
+
Get maximum attempts for anti-truncation continuation.
|
| 185 |
+
|
| 186 |
+
Environment variable: ANTI_TRUNCATION_MAX_ATTEMPTS
|
| 187 |
+
Database config key: anti_truncation_max_attempts
|
| 188 |
+
Default: 3
|
| 189 |
+
"""
|
| 190 |
+
env_value = os.getenv("ANTI_TRUNCATION_MAX_ATTEMPTS")
|
| 191 |
+
if env_value:
|
| 192 |
+
try:
|
| 193 |
+
return int(env_value)
|
| 194 |
+
except ValueError:
|
| 195 |
+
pass
|
| 196 |
+
|
| 197 |
+
return int(await get_config_value("anti_truncation_max_attempts", 3))
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
# Server Configuration
|
| 201 |
+
async def get_server_host() -> str:
|
| 202 |
+
"""
|
| 203 |
+
Get server host setting.
|
| 204 |
+
|
| 205 |
+
Environment variable: HOST
|
| 206 |
+
Database config key: host
|
| 207 |
+
Default: 0.0.0.0
|
| 208 |
+
"""
|
| 209 |
+
return str(await get_config_value("host", "0.0.0.0", "HOST"))
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
async def get_server_port() -> int:
|
| 213 |
+
"""
|
| 214 |
+
Get server port setting.
|
| 215 |
+
|
| 216 |
+
Environment variable: PORT
|
| 217 |
+
Database config key: port
|
| 218 |
+
Default: 7861
|
| 219 |
+
"""
|
| 220 |
+
env_value = os.getenv("PORT")
|
| 221 |
+
if env_value:
|
| 222 |
+
try:
|
| 223 |
+
return int(env_value)
|
| 224 |
+
except ValueError:
|
| 225 |
+
pass
|
| 226 |
+
|
| 227 |
+
return int(await get_config_value("port", 7861))
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
async def get_api_password() -> str:
|
| 231 |
+
"""
|
| 232 |
+
Get API password setting for chat endpoints.
|
| 233 |
+
|
| 234 |
+
Environment variable: API_PASSWORD
|
| 235 |
+
Database config key: api_password
|
| 236 |
+
Default: Uses PASSWORD env var for compatibility, otherwise 'pwd'
|
| 237 |
+
"""
|
| 238 |
+
# 优先使用 API_PASSWORD,如果没有则使用通用 PASSWORD 保证兼容性
|
| 239 |
+
api_password = await get_config_value("api_password", None, "API_PASSWORD")
|
| 240 |
+
if api_password is not None:
|
| 241 |
+
return str(api_password)
|
| 242 |
+
|
| 243 |
+
# 兼容性:使用通用密码
|
| 244 |
+
return str(await get_config_value("password", "pwd", "PASSWORD"))
|
| 245 |
+
|
| 246 |
+
|
| 247 |
+
async def get_panel_password() -> str:
|
| 248 |
+
"""
|
| 249 |
+
Get panel password setting for web interface.
|
| 250 |
+
|
| 251 |
+
Environment variable: PANEL_PASSWORD
|
| 252 |
+
Database config key: panel_password
|
| 253 |
+
Default: Uses PASSWORD env var for compatibility, otherwise 'pwd'
|
| 254 |
+
"""
|
| 255 |
+
# 优先使用 PANEL_PASSWORD,如果没有则使用通用 PASSWORD 保证兼容性
|
| 256 |
+
panel_password = await get_config_value("panel_password", None, "PANEL_PASSWORD")
|
| 257 |
+
if panel_password is not None:
|
| 258 |
+
return str(panel_password)
|
| 259 |
+
|
| 260 |
+
# 兼容性:使用通用密码
|
| 261 |
+
return str(await get_config_value("password", "pwd", "PASSWORD"))
|
| 262 |
+
|
| 263 |
+
|
| 264 |
+
async def get_server_password() -> str:
|
| 265 |
+
"""
|
| 266 |
+
Get server password setting (deprecated, use get_api_password or get_panel_password).
|
| 267 |
+
|
| 268 |
+
Environment variable: PASSWORD
|
| 269 |
+
Database config key: password
|
| 270 |
+
Default: pwd
|
| 271 |
+
"""
|
| 272 |
+
return str(await get_config_value("password", "pwd", "PASSWORD"))
|
| 273 |
+
|
| 274 |
+
|
| 275 |
+
async def get_credentials_dir() -> str:
|
| 276 |
+
"""
|
| 277 |
+
Get credentials directory setting.
|
| 278 |
+
|
| 279 |
+
Environment variable: CREDENTIALS_DIR
|
| 280 |
+
Database config key: credentials_dir
|
| 281 |
+
Default: ./creds
|
| 282 |
+
"""
|
| 283 |
+
return str(await get_config_value("credentials_dir", "./creds", "CREDENTIALS_DIR"))
|
| 284 |
+
|
| 285 |
+
|
| 286 |
+
async def get_code_assist_endpoint() -> str:
|
| 287 |
+
"""
|
| 288 |
+
Get Code Assist endpoint setting.
|
| 289 |
+
|
| 290 |
+
Environment variable: CODE_ASSIST_ENDPOINT
|
| 291 |
+
Database config key: code_assist_endpoint
|
| 292 |
+
Default: https://cloudcode-pa.googleapis.com
|
| 293 |
+
"""
|
| 294 |
+
return str(
|
| 295 |
+
await get_config_value(
|
| 296 |
+
"code_assist_endpoint", "https://cloudcode-pa.googleapis.com", "CODE_ASSIST_ENDPOINT"
|
| 297 |
+
)
|
| 298 |
+
)
|
| 299 |
+
|
| 300 |
+
|
| 301 |
+
async def get_compatibility_mode_enabled() -> bool:
|
| 302 |
+
"""
|
| 303 |
+
Get compatibility mode setting.
|
| 304 |
+
|
| 305 |
+
兼容性模式:启用后所有system消息全部转换成user,停用system_instructions。
|
| 306 |
+
该选项可能会降低模型理解能力,但是能避免流式空回的情况。
|
| 307 |
+
|
| 308 |
+
Environment variable: COMPATIBILITY_MODE
|
| 309 |
+
Database config key: compatibility_mode_enabled
|
| 310 |
+
Default: False
|
| 311 |
+
"""
|
| 312 |
+
env_value = os.getenv("COMPATIBILITY_MODE")
|
| 313 |
+
if env_value:
|
| 314 |
+
return env_value.lower() in ("true", "1", "yes", "on")
|
| 315 |
+
|
| 316 |
+
return bool(await get_config_value("compatibility_mode_enabled", False))
|
| 317 |
+
|
| 318 |
+
|
| 319 |
+
async def get_return_thoughts_to_frontend() -> bool:
|
| 320 |
+
"""
|
| 321 |
+
Get return thoughts to frontend setting.
|
| 322 |
+
|
| 323 |
+
控制是否将思维链返回到前端。
|
| 324 |
+
启用后,思维链会在响应中返回;禁用后,思维链会在响应中被过滤掉。
|
| 325 |
+
|
| 326 |
+
Environment variable: RETURN_THOUGHTS_TO_FRONTEND
|
| 327 |
+
Database config key: return_thoughts_to_frontend
|
| 328 |
+
Default: True
|
| 329 |
+
"""
|
| 330 |
+
env_value = os.getenv("RETURN_THOUGHTS_TO_FRONTEND")
|
| 331 |
+
if env_value:
|
| 332 |
+
return env_value.lower() in ("true", "1", "yes", "on")
|
| 333 |
+
|
| 334 |
+
return bool(await get_config_value("return_thoughts_to_frontend", True))
|
| 335 |
+
|
| 336 |
+
|
| 337 |
+
async def get_antigravity_stream2nostream() -> bool:
|
| 338 |
+
"""
|
| 339 |
+
Get use stream for non-stream setting.
|
| 340 |
+
|
| 341 |
+
控制antigravity非流式请求是否使用流式API并收集为完整响应。
|
| 342 |
+
启用后,非流式请求将在后端使用流式API,然后收集所有块后再返回完整响应。
|
| 343 |
+
|
| 344 |
+
Environment variable: ANTIGRAVITY_STREAM2NOSTREAM
|
| 345 |
+
Database config key: antigravity_stream2nostream
|
| 346 |
+
Default: True
|
| 347 |
+
"""
|
| 348 |
+
env_value = os.getenv("ANTIGRAVITY_STREAM2NOSTREAM")
|
| 349 |
+
if env_value:
|
| 350 |
+
return env_value.lower() in ("true", "1", "yes", "on")
|
| 351 |
+
|
| 352 |
+
return bool(await get_config_value("antigravity_stream2nostream", True))
|
| 353 |
+
|
| 354 |
+
|
| 355 |
+
async def get_oauth_proxy_url() -> str:
|
| 356 |
+
"""
|
| 357 |
+
Get OAuth proxy URL setting.
|
| 358 |
+
|
| 359 |
+
用于Google OAuth2认证的代理URL。
|
| 360 |
+
|
| 361 |
+
Environment variable: OAUTH_PROXY_URL
|
| 362 |
+
Database config key: oauth_proxy_url
|
| 363 |
+
Default: https://oauth2.googleapis.com
|
| 364 |
+
"""
|
| 365 |
+
return str(
|
| 366 |
+
await get_config_value(
|
| 367 |
+
"oauth_proxy_url", "https://oauth2.googleapis.com", "OAUTH_PROXY_URL"
|
| 368 |
+
)
|
| 369 |
+
)
|
| 370 |
+
|
| 371 |
+
|
| 372 |
+
async def get_googleapis_proxy_url() -> str:
|
| 373 |
+
"""
|
| 374 |
+
Get Google APIs proxy URL setting.
|
| 375 |
+
|
| 376 |
+
用于Google APIs调用的代理URL。
|
| 377 |
+
|
| 378 |
+
Environment variable: GOOGLEAPIS_PROXY_URL
|
| 379 |
+
Database config key: googleapis_proxy_url
|
| 380 |
+
Default: https://www.googleapis.com
|
| 381 |
+
"""
|
| 382 |
+
return str(
|
| 383 |
+
await get_config_value(
|
| 384 |
+
"googleapis_proxy_url", "https://www.googleapis.com", "GOOGLEAPIS_PROXY_URL"
|
| 385 |
+
)
|
| 386 |
+
)
|
| 387 |
+
|
| 388 |
+
|
| 389 |
+
async def get_resource_manager_api_url() -> str:
|
| 390 |
+
"""
|
| 391 |
+
Get Google Cloud Resource Manager API URL setting.
|
| 392 |
+
|
| 393 |
+
用于Google Cloud Resource Manager API的URL。
|
| 394 |
+
|
| 395 |
+
Environment variable: RESOURCE_MANAGER_API_URL
|
| 396 |
+
Database config key: resource_manager_api_url
|
| 397 |
+
Default: https://cloudresourcemanager.googleapis.com
|
| 398 |
+
"""
|
| 399 |
+
return str(
|
| 400 |
+
await get_config_value(
|
| 401 |
+
"resource_manager_api_url",
|
| 402 |
+
"https://cloudresourcemanager.googleapis.com",
|
| 403 |
+
"RESOURCE_MANAGER_API_URL",
|
| 404 |
+
)
|
| 405 |
+
)
|
| 406 |
+
|
| 407 |
+
|
| 408 |
+
async def get_service_usage_api_url() -> str:
|
| 409 |
+
"""
|
| 410 |
+
Get Google Cloud Service Usage API URL setting.
|
| 411 |
+
|
| 412 |
+
用于Google Cloud Service Usage API的URL。
|
| 413 |
+
|
| 414 |
+
Environment variable: SERVICE_USAGE_API_URL
|
| 415 |
+
Database config key: service_usage_api_url
|
| 416 |
+
Default: https://serviceusage.googleapis.com
|
| 417 |
+
"""
|
| 418 |
+
return str(
|
| 419 |
+
await get_config_value(
|
| 420 |
+
"service_usage_api_url", "https://serviceusage.googleapis.com", "SERVICE_USAGE_API_URL"
|
| 421 |
+
)
|
| 422 |
+
)
|
| 423 |
+
|
| 424 |
+
|
| 425 |
+
async def get_antigravity_api_url() -> str:
|
| 426 |
+
"""
|
| 427 |
+
Get Antigravity API URL setting.
|
| 428 |
+
|
| 429 |
+
用于Google Antigravity API的URL。
|
| 430 |
+
|
| 431 |
+
Environment variable: ANTIGRAVITY_API_URL
|
| 432 |
+
Database config key: antigravity_api_url
|
| 433 |
+
Default: https://daily-cloudcode-pa.sandbox.googleapis.com
|
| 434 |
+
"""
|
| 435 |
+
return str(
|
| 436 |
+
await get_config_value(
|
| 437 |
+
"antigravity_api_url",
|
| 438 |
+
"https://daily-cloudcode-pa.sandbox.googleapis.com",
|
| 439 |
+
"ANTIGRAVITY_API_URL",
|
| 440 |
+
)
|
| 441 |
+
)
|
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,85 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
healthcheck:
|
| 43 |
+
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)\""]
|
| 44 |
+
interval: 30s
|
| 45 |
+
timeout: 10s
|
| 46 |
+
retries: 3
|
| 47 |
+
start_period: 40s
|
| 48 |
+
|
| 49 |
+
# Example with Redis for distributed storage
|
| 50 |
+
# redis:
|
| 51 |
+
# image: redis:7-alpine
|
| 52 |
+
# container_name: gcli2api-redis
|
| 53 |
+
# restart: unless-stopped
|
| 54 |
+
# ports:
|
| 55 |
+
# - "6379:6379"
|
| 56 |
+
# volumes:
|
| 57 |
+
# - redis_data:/data
|
| 58 |
+
# command: redis-server --appendonly yes
|
| 59 |
+
# healthcheck:
|
| 60 |
+
# test: ["CMD", "redis-cli", "ping"]
|
| 61 |
+
# interval: 10s
|
| 62 |
+
# timeout: 3s
|
| 63 |
+
# retries: 3
|
| 64 |
+
|
| 65 |
+
# Example with MongoDB for distributed storage
|
| 66 |
+
# mongodb:
|
| 67 |
+
# image: mongo:7
|
| 68 |
+
# container_name: gcli2api-mongodb
|
| 69 |
+
# restart: unless-stopped
|
| 70 |
+
# environment:
|
| 71 |
+
# MONGO_INITDB_ROOT_USERNAME: ${MONGO_USER:-admin}
|
| 72 |
+
# MONGO_INITDB_ROOT_PASSWORD: ${MONGO_PASSWORD:-password}
|
| 73 |
+
# ports:
|
| 74 |
+
# - "27017:27017"
|
| 75 |
+
# volumes:
|
| 76 |
+
# - mongodb_data:/data/db
|
| 77 |
+
# healthcheck:
|
| 78 |
+
# test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"]
|
| 79 |
+
# interval: 10s
|
| 80 |
+
# timeout: 5s
|
| 81 |
+
# retries: 3
|
| 82 |
+
|
| 83 |
+
#volumes:
|
| 84 |
+
# redis_data:
|
| 85 |
+
# mongodb_data:
|
docs/README_EN.md
ADDED
|
@@ -0,0 +1,958 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 10 |
+
|
| 11 |
+
## 🚀 Quick Deploy
|
| 12 |
+
|
| 13 |
+
[](https://zeabur.com/templates/97VMEF?referralCode=su-kaka)
|
| 14 |
+
---
|
| 15 |
+
|
| 16 |
+
## ⚠️ License Declaration
|
| 17 |
+
|
| 18 |
+
**This project is licensed under the Cooperative Non-Commercial License (CNC-1.0)**
|
| 19 |
+
|
| 20 |
+
This is a strict anti-commercial open source license. Please refer to the [LICENSE](../LICENSE) file for details.
|
| 21 |
+
|
| 22 |
+
### ✅ Permitted Uses:
|
| 23 |
+
- Personal learning, research, and educational purposes
|
| 24 |
+
- Non-profit organization use
|
| 25 |
+
- Open source project integration (must comply with the same license)
|
| 26 |
+
- Academic research and publication
|
| 27 |
+
|
| 28 |
+
### ❌ Prohibited Uses:
|
| 29 |
+
- Any form of commercial use
|
| 30 |
+
- Enterprise use with annual revenue exceeding $1 million
|
| 31 |
+
- Venture capital-backed or publicly traded companies
|
| 32 |
+
- Providing paid services or products
|
| 33 |
+
- Commercial competitive use
|
| 34 |
+
|
| 35 |
+
---
|
| 36 |
+
|
| 37 |
+
## Core Features
|
| 38 |
+
|
| 39 |
+
### 🔄 API Endpoints and Format Support
|
| 40 |
+
|
| 41 |
+
**Multi-endpoint Multi-format Support**
|
| 42 |
+
- **OpenAI Compatible Endpoints**: `/v1/chat/completions` and `/v1/models`
|
| 43 |
+
- Supports standard OpenAI format (messages structure)
|
| 44 |
+
- Supports Gemini native format (contents structure)
|
| 45 |
+
- Automatic format detection and conversion, no manual switching required
|
| 46 |
+
- Supports multimodal input (text + images)
|
| 47 |
+
- **Gemini Native Endpoints**: `/v1/models/{model}:generateContent` and `streamGenerateContent`
|
| 48 |
+
- Supports complete Gemini native API specifications
|
| 49 |
+
- Multiple authentication methods: Bearer Token, x-goog-api-key header, URL parameter key
|
| 50 |
+
- **Claude Format Compatibility**: Full support for Claude API format
|
| 51 |
+
- Endpoint: `/v1/messages` (follows Claude API specification)
|
| 52 |
+
- Supports Claude standard messages format
|
| 53 |
+
- Supports system parameter and Claude-specific features
|
| 54 |
+
- Automatically converts to backend-supported format
|
| 55 |
+
- **Antigravity API Support**: Supports OpenAI, Gemini, and Claude formats
|
| 56 |
+
- OpenAI format endpoint: `/antigravity/v1/chat/completions`
|
| 57 |
+
- Gemini format endpoint: `/antigravity/v1/models/{model}:generateContent` and `streamGenerateContent`
|
| 58 |
+
- Claude format endpoint: `/antigravity/v1/messages`
|
| 59 |
+
- Supports all Antigravity models (Claude, Gemini, etc.)
|
| 60 |
+
- Automatic model name mapping and thinking mode detection
|
| 61 |
+
|
| 62 |
+
### 🔐 Authentication and Security Management
|
| 63 |
+
|
| 64 |
+
**Flexible Password Management**
|
| 65 |
+
- **Separate Password Support**: API password (chat endpoints) and control panel password can be set independently
|
| 66 |
+
- **Multiple Authentication Methods**: Supports Authorization Bearer, x-goog-api-key header, URL parameters, etc.
|
| 67 |
+
- **JWT Token Authentication**: Control panel supports JWT token authentication
|
| 68 |
+
- **User Email Retrieval**: Automatically retrieves and displays Google account email addresses
|
| 69 |
+
|
| 70 |
+
### 📊 Intelligent Credential Management System
|
| 71 |
+
|
| 72 |
+
**Advanced Credential Management**
|
| 73 |
+
- Multiple Google OAuth credential automatic rotation
|
| 74 |
+
- Enhanced stability through redundant authentication
|
| 75 |
+
- Load balancing and concurrent request support
|
| 76 |
+
- Automatic failure detection and credential disabling
|
| 77 |
+
- Credential usage statistics and quota management
|
| 78 |
+
- Support for manual enable/disable credential files
|
| 79 |
+
- Batch credential file operations (enable, disable, delete)
|
| 80 |
+
|
| 81 |
+
**Credential Status Monitoring**
|
| 82 |
+
- Real-time credential health checks
|
| 83 |
+
- Error code tracking (429, 403, 500, etc.)
|
| 84 |
+
- Automatic banning mechanism (configurable)
|
| 85 |
+
- Credential rotation strategy (based on call count)
|
| 86 |
+
- Usage statistics and quota monitoring
|
| 87 |
+
|
| 88 |
+
### 🌊 Streaming and Response Processing
|
| 89 |
+
|
| 90 |
+
**Multiple Streaming Support**
|
| 91 |
+
- True real-time streaming responses
|
| 92 |
+
- Fake streaming mode (for compatibility)
|
| 93 |
+
- Streaming anti-truncation feature (prevents answer truncation)
|
| 94 |
+
- Asynchronous task management and timeout handling
|
| 95 |
+
|
| 96 |
+
**Response Optimization**
|
| 97 |
+
- Thinking chain content separation
|
| 98 |
+
- Reasoning process (reasoning_content) handling
|
| 99 |
+
- Multi-turn conversation context management
|
| 100 |
+
- Compatibility mode (converts system messages to user messages)
|
| 101 |
+
|
| 102 |
+
### 🎛️ Web Management Console
|
| 103 |
+
|
| 104 |
+
**Full-featured Web Interface**
|
| 105 |
+
- OAuth authentication flow management (supports GCLI and Antigravity dual modes)
|
| 106 |
+
- Credential file upload, download, and management
|
| 107 |
+
- Real-time log viewing (WebSocket)
|
| 108 |
+
- System configuration management
|
| 109 |
+
- Usage statistics and monitoring dashboard
|
| 110 |
+
- Mobile-friendly interface
|
| 111 |
+
|
| 112 |
+
**Batch Operation Support**
|
| 113 |
+
- ZIP file batch credential upload (GCLI and Antigravity)
|
| 114 |
+
- Batch enable/disable/delete credentials
|
| 115 |
+
- Batch user email retrieval
|
| 116 |
+
- Batch configuration management
|
| 117 |
+
- Unified batch upload interface for all credential types
|
| 118 |
+
|
| 119 |
+
### 📈 Usage Statistics and Monitoring
|
| 120 |
+
|
| 121 |
+
**Detailed Usage Statistics**
|
| 122 |
+
- Call count statistics by credential file
|
| 123 |
+
- Gemini 2.5 Pro model specific statistics
|
| 124 |
+
- Daily quota management (UTC+7 reset)
|
| 125 |
+
- Aggregated statistics and analysis
|
| 126 |
+
- Custom daily limit configuration
|
| 127 |
+
|
| 128 |
+
**Real-time Monitoring**
|
| 129 |
+
- WebSocket real-time log streams
|
| 130 |
+
- System status monitoring
|
| 131 |
+
- Credential health status
|
| 132 |
+
- API call success rate statistics
|
| 133 |
+
|
| 134 |
+
### 🔧 Advanced Configuration and Customization
|
| 135 |
+
|
| 136 |
+
**Network and Proxy Configuration**
|
| 137 |
+
- HTTP/HTTPS proxy support
|
| 138 |
+
- Proxy endpoint configuration (OAuth, Google APIs, metadata service)
|
| 139 |
+
- Timeout and retry configuration
|
| 140 |
+
- Network error handling and recovery
|
| 141 |
+
|
| 142 |
+
**Performance and Stability Configuration**
|
| 143 |
+
- 429 error automatic retry (configurable interval and attempts)
|
| 144 |
+
- Anti-truncation maximum retry attempts
|
| 145 |
+
- Credential rotation strategy
|
| 146 |
+
- Concurrent request management
|
| 147 |
+
|
| 148 |
+
**Logging and Debugging**
|
| 149 |
+
- Multi-level logging system (DEBUG, INFO, WARNING, ERROR)
|
| 150 |
+
- Log file management
|
| 151 |
+
- Real-time log streams
|
| 152 |
+
- Log download and clearing
|
| 153 |
+
|
| 154 |
+
### 🔄 Environment Variables and Configuration Management
|
| 155 |
+
|
| 156 |
+
**Flexible Configuration Methods**
|
| 157 |
+
- TOML configuration file support
|
| 158 |
+
- Environment variable configuration
|
| 159 |
+
- Hot configuration updates (partial configuration items)
|
| 160 |
+
- Configuration locking (environment variable priority)
|
| 161 |
+
|
| 162 |
+
**Environment Variable Credential Support**
|
| 163 |
+
- `GCLI_CREDS_*` format environment variable import
|
| 164 |
+
- Automatic loading of environment variable credentials
|
| 165 |
+
- Base64 encoded credential support
|
| 166 |
+
- Docker container friendly
|
| 167 |
+
|
| 168 |
+
## Supported Models
|
| 169 |
+
|
| 170 |
+
All models have 1M context window capacity. Each credential file provides 1000 request quota.
|
| 171 |
+
|
| 172 |
+
### 🤖 Base Models
|
| 173 |
+
- `gemini-2.5-pro`
|
| 174 |
+
- `gemini-3-pro-preview`
|
| 175 |
+
|
| 176 |
+
### 🧠 Thinking Models
|
| 177 |
+
- `gemini-2.5-pro-maxthinking`: Maximum thinking budget mode
|
| 178 |
+
- `gemini-2.5-pro-nothinking`: No thinking mode
|
| 179 |
+
- Supports custom thinking budget configuration
|
| 180 |
+
- Automatic separation of thinking content and final answers
|
| 181 |
+
|
| 182 |
+
### 🔍 Search-Enhanced Models
|
| 183 |
+
- `gemini-2.5-pro-search`: Model with integrated search functionality
|
| 184 |
+
|
| 185 |
+
### 🌊 Special Feature Variants
|
| 186 |
+
- **Fake Streaming Mode**: Add `-假流式` suffix to any model name
|
| 187 |
+
- Example: `gemini-2.5-pro-假流式`
|
| 188 |
+
- For scenarios requiring streaming responses but server doesn't support true streaming
|
| 189 |
+
- **Streaming Anti-truncation Mode**: Add `流式抗截断/` prefix to model name
|
| 190 |
+
- Example: `流式抗截断/gemini-2.5-pro`
|
| 191 |
+
- Automatically detects response truncation and retries to ensure complete answers
|
| 192 |
+
|
| 193 |
+
### 🔧 Automatic Model Feature Detection
|
| 194 |
+
- System automatically recognizes feature identifiers in model names
|
| 195 |
+
- Transparently handles feature mode transitions
|
| 196 |
+
- Supports feature combination usage
|
| 197 |
+
|
| 198 |
+
---
|
| 199 |
+
|
| 200 |
+
## Installation Guide
|
| 201 |
+
|
| 202 |
+
### Termux Environment
|
| 203 |
+
|
| 204 |
+
**Initial Installation**
|
| 205 |
+
```bash
|
| 206 |
+
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
|
| 207 |
+
```
|
| 208 |
+
|
| 209 |
+
**Restart Service**
|
| 210 |
+
```bash
|
| 211 |
+
cd gcli2api
|
| 212 |
+
bash termux-start.sh
|
| 213 |
+
```
|
| 214 |
+
|
| 215 |
+
### Windows Environment
|
| 216 |
+
|
| 217 |
+
**Initial Installation**
|
| 218 |
+
```powershell
|
| 219 |
+
iex (iwr "https://raw.githubusercontent.com/su-kaka/gcli2api/refs/heads/master/install.ps1" -UseBasicParsing).Content
|
| 220 |
+
```
|
| 221 |
+
|
| 222 |
+
**Restart Service**
|
| 223 |
+
Double-click to execute `start.bat`
|
| 224 |
+
|
| 225 |
+
### Linux Environment
|
| 226 |
+
|
| 227 |
+
**Initial Installation**
|
| 228 |
+
```bash
|
| 229 |
+
curl -o install.sh "https://raw.githubusercontent.com/su-kaka/gcli2api/refs/heads/master/install.sh" && chmod +x install.sh && ./install.sh
|
| 230 |
+
```
|
| 231 |
+
|
| 232 |
+
**Restart Service**
|
| 233 |
+
```bash
|
| 234 |
+
cd gcli2api
|
| 235 |
+
bash start.sh
|
| 236 |
+
```
|
| 237 |
+
|
| 238 |
+
### Docker Environment
|
| 239 |
+
|
| 240 |
+
**Docker Run Command**
|
| 241 |
+
```bash
|
| 242 |
+
# Using universal password
|
| 243 |
+
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
|
| 244 |
+
|
| 245 |
+
# Using separate passwords
|
| 246 |
+
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
|
| 247 |
+
```
|
| 248 |
+
|
| 249 |
+
**Docker Compose Run Command**
|
| 250 |
+
1. Save the following content as `docker-compose.yml` file:
|
| 251 |
+
```yaml
|
| 252 |
+
version: '3.8'
|
| 253 |
+
|
| 254 |
+
services:
|
| 255 |
+
gcli2api:
|
| 256 |
+
image: ghcr.io/su-kaka/gcli2api:latest
|
| 257 |
+
container_name: gcli2api
|
| 258 |
+
restart: unless-stopped
|
| 259 |
+
network_mode: host
|
| 260 |
+
environment:
|
| 261 |
+
# Using universal password (recommended for simple deployment)
|
| 262 |
+
- PASSWORD=pwd
|
| 263 |
+
- PORT=7861
|
| 264 |
+
# Or use separate passwords (recommended for production)
|
| 265 |
+
# - API_PASSWORD=your_api_password
|
| 266 |
+
# - PANEL_PASSWORD=your_panel_password
|
| 267 |
+
volumes:
|
| 268 |
+
- ./data/creds:/app/creds
|
| 269 |
+
healthcheck:
|
| 270 |
+
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)\""]
|
| 271 |
+
interval: 30s
|
| 272 |
+
timeout: 10s
|
| 273 |
+
retries: 3
|
| 274 |
+
start_period: 40s
|
| 275 |
+
```
|
| 276 |
+
2. Start the service:
|
| 277 |
+
```bash
|
| 278 |
+
docker-compose up -d
|
| 279 |
+
```
|
| 280 |
+
|
| 281 |
+
---
|
| 282 |
+
|
| 283 |
+
## ⚠️ Important Notes
|
| 284 |
+
|
| 285 |
+
- The current OAuth authentication process **only supports localhost access**, meaning authentication must be completed through `http://127.0.0.1:7861/auth` (default port 7861, modifiable via PORT environment variable).
|
| 286 |
+
- **For deployment on cloud servers or other remote environments, please first run the service locally and complete OAuth authentication to obtain the generated json credential files (located in the `./geminicli/creds` directory), then upload these files via the auth panel.**
|
| 287 |
+
- **Please strictly comply with usage restrictions, only for personal learning and non-commercial purposes**
|
| 288 |
+
|
| 289 |
+
---
|
| 290 |
+
|
| 291 |
+
## Configuration Instructions
|
| 292 |
+
|
| 293 |
+
1. Visit `http://127.0.0.1:7861/auth` (default port, modifiable via PORT environment variable)
|
| 294 |
+
2. Complete OAuth authentication flow (default password: `pwd`, modifiable via environment variables)
|
| 295 |
+
- **GCLI Mode**: For obtaining Google Cloud Gemini API credentials
|
| 296 |
+
- **Antigravity Mode**: For obtaining Google Antigravity API credentials
|
| 297 |
+
3. Configure client:
|
| 298 |
+
|
| 299 |
+
**OpenAI Compatible Client:**
|
| 300 |
+
- **Endpoint Address**: `http://127.0.0.1:7861/v1`
|
| 301 |
+
- **API Key**: `pwd` (default value, modifiable via API_PASSWORD or PASSWORD environment variables)
|
| 302 |
+
|
| 303 |
+
**Gemini Native Client:**
|
| 304 |
+
- **Endpoint Address**: `http://127.0.0.1:7861`
|
| 305 |
+
- **Authentication Methods**:
|
| 306 |
+
- `Authorization: Bearer your_api_password`
|
| 307 |
+
- `x-goog-api-key: your_api_password`
|
| 308 |
+
- URL parameter: `?key=your_api_password`
|
| 309 |
+
|
| 310 |
+
### 🌟 Dual Authentication Mode Support
|
| 311 |
+
|
| 312 |
+
**GCLI Authentication Mode**
|
| 313 |
+
- Standard Google Cloud Gemini API authentication
|
| 314 |
+
- Supports OAuth2.0 authentication flow
|
| 315 |
+
- Automatically enables required Google Cloud APIs
|
| 316 |
+
|
| 317 |
+
**Antigravity Authentication Mode**
|
| 318 |
+
- Dedicated authentication for Google Antigravity API
|
| 319 |
+
- Independent credential management system
|
| 320 |
+
- Supports batch upload and management
|
| 321 |
+
- Completely isolated from GCLI credentials
|
| 322 |
+
|
| 323 |
+
**Unified Management Interface**
|
| 324 |
+
- Manage both credential types in the "Batch Upload" tab
|
| 325 |
+
- Upper section: GCLI credential batch upload (blue theme)
|
| 326 |
+
- Lower section: Antigravity credential batch upload (green theme)
|
| 327 |
+
- Separate credential management tabs for each type
|
| 328 |
+
|
| 329 |
+
## 💾 Data Storage Mode
|
| 330 |
+
|
| 331 |
+
### 🌟 Storage Backend Support
|
| 332 |
+
|
| 333 |
+
gcli2api supports two storage backends: **Local SQLite (Default)** and **MongoDB (Cloud Distributed Storage)**
|
| 334 |
+
|
| 335 |
+
### 📁 Local SQLite Storage (Default)
|
| 336 |
+
|
| 337 |
+
**Default Storage Method**
|
| 338 |
+
- No configuration required, works out of the box
|
| 339 |
+
- Data is stored in a local SQLite database
|
| 340 |
+
- Suitable for single-machine deployment and personal use
|
| 341 |
+
- Automatically creates and manages database files
|
| 342 |
+
|
| 343 |
+
### 🍃 MongoDB Cloud Storage Mode
|
| 344 |
+
|
| 345 |
+
**Cloud Distributed Storage Solution**
|
| 346 |
+
|
| 347 |
+
When multi-instance deployment or cloud storage is needed, MongoDB storage mode can be enabled.
|
| 348 |
+
|
| 349 |
+
### ⚙️ Enable MongoDB Mode
|
| 350 |
+
|
| 351 |
+
**Step 1: Configure MongoDB Connection**
|
| 352 |
+
```bash
|
| 353 |
+
# Local MongoDB
|
| 354 |
+
export MONGODB_URI="mongodb://localhost:27017"
|
| 355 |
+
|
| 356 |
+
# MongoDB Atlas cloud service
|
| 357 |
+
export MONGODB_URI="mongodb+srv://username:password@cluster.mongodb.net"
|
| 358 |
+
|
| 359 |
+
# MongoDB with authentication
|
| 360 |
+
export MONGODB_URI="mongodb://admin:password@localhost:27017/admin"
|
| 361 |
+
|
| 362 |
+
# Optional: Custom database name (default: gcli2api)
|
| 363 |
+
export MONGODB_DATABASE="my_gcli_db"
|
| 364 |
+
```
|
| 365 |
+
|
| 366 |
+
**Step 2: Start Application**
|
| 367 |
+
```bash
|
| 368 |
+
# Application will automatically detect MongoDB configuration and use MongoDB storage
|
| 369 |
+
python web.py
|
| 370 |
+
```
|
| 371 |
+
|
| 372 |
+
**Docker Environment using MongoDB**
|
| 373 |
+
```bash
|
| 374 |
+
# Single MongoDB deployment
|
| 375 |
+
docker run -d --name gcli2api \
|
| 376 |
+
-e MONGODB_URI="mongodb://mongodb:27017" \
|
| 377 |
+
-e API_PASSWORD=your_password \
|
| 378 |
+
--network your_network \
|
| 379 |
+
ghcr.io/su-kaka/gcli2api:latest
|
| 380 |
+
|
| 381 |
+
# Using MongoDB Atlas
|
| 382 |
+
docker run -d --name gcli2api \
|
| 383 |
+
-e MONGODB_URI="mongodb+srv://user:pass@cluster.mongodb.net/gcli2api" \
|
| 384 |
+
-e API_PASSWORD=your_password \
|
| 385 |
+
-p 7861:7861 \
|
| 386 |
+
ghcr.io/su-kaka/gcli2api:latest
|
| 387 |
+
```
|
| 388 |
+
|
| 389 |
+
**Docker Compose Example**
|
| 390 |
+
```yaml
|
| 391 |
+
version: '3.8'
|
| 392 |
+
|
| 393 |
+
services:
|
| 394 |
+
mongodb:
|
| 395 |
+
image: mongo:7
|
| 396 |
+
container_name: gcli2api-mongodb
|
| 397 |
+
restart: unless-stopped
|
| 398 |
+
environment:
|
| 399 |
+
MONGO_INITDB_ROOT_USERNAME: admin
|
| 400 |
+
MONGO_INITDB_ROOT_PASSWORD: password123
|
| 401 |
+
volumes:
|
| 402 |
+
- mongodb_data:/data/db
|
| 403 |
+
ports:
|
| 404 |
+
- "27017:27017"
|
| 405 |
+
|
| 406 |
+
gcli2api:
|
| 407 |
+
image: ghcr.io/su-kaka/gcli2api:latest
|
| 408 |
+
container_name: gcli2api
|
| 409 |
+
restart: unless-stopped
|
| 410 |
+
depends_on:
|
| 411 |
+
- mongodb
|
| 412 |
+
environment:
|
| 413 |
+
- MONGODB_URI=mongodb://admin:password123@mongodb:27017/admin
|
| 414 |
+
- MONGODB_DATABASE=gcli2api
|
| 415 |
+
- API_PASSWORD=your_api_password
|
| 416 |
+
- PORT=7861
|
| 417 |
+
ports:
|
| 418 |
+
- "7861:7861"
|
| 419 |
+
|
| 420 |
+
volumes:
|
| 421 |
+
mongodb_data:
|
| 422 |
+
```
|
| 423 |
+
|
| 424 |
+
### 🛠️ Troubleshooting
|
| 425 |
+
|
| 426 |
+
**Common Issue Solutions**
|
| 427 |
+
|
| 428 |
+
```bash
|
| 429 |
+
# Check MongoDB connection
|
| 430 |
+
python mongodb_setup.py check
|
| 431 |
+
|
| 432 |
+
# View detailed status information
|
| 433 |
+
python mongodb_setup.py status
|
| 434 |
+
|
| 435 |
+
# Verify data migration results
|
| 436 |
+
python -c "
|
| 437 |
+
import asyncio
|
| 438 |
+
from src.storage_adapter import get_storage_adapter
|
| 439 |
+
|
| 440 |
+
async def test():
|
| 441 |
+
storage = await get_storage_adapter()
|
| 442 |
+
info = await storage.get_backend_info()
|
| 443 |
+
print(f'Current mode: {info[\"backend_type\"]}')
|
| 444 |
+
if info['backend_type'] == 'mongodb':
|
| 445 |
+
print(f'Database: {info.get(\"database_name\", \"Unknown\")}')
|
| 446 |
+
|
| 447 |
+
asyncio.run(test())
|
| 448 |
+
"
|
| 449 |
+
```
|
| 450 |
+
|
| 451 |
+
**Migration Failure Handling**
|
| 452 |
+
```bash
|
| 453 |
+
# If migration is interrupted, re-run
|
| 454 |
+
python mongodb_setup.py migrate
|
| 455 |
+
|
| 456 |
+
# To rollback to local SQLite mode, remove MONGODB_URI environment variable
|
| 457 |
+
unset MONGODB_URI
|
| 458 |
+
# Then export data from MongoDB
|
| 459 |
+
python mongodb_setup.py export
|
| 460 |
+
```
|
| 461 |
+
|
| 462 |
+
### 🔧 Advanced Configuration
|
| 463 |
+
|
| 464 |
+
**MongoDB Connection Optimization**
|
| 465 |
+
```bash
|
| 466 |
+
# Connection pool and timeout configuration
|
| 467 |
+
export MONGODB_URI="mongodb://localhost:27017?maxPoolSize=10&serverSelectionTimeoutMS=5000"
|
| 468 |
+
|
| 469 |
+
# Replica set configuration
|
| 470 |
+
export MONGODB_URI="mongodb://host1:27017,host2:27017,host3:27017/gcli2api?replicaSet=myReplicaSet"
|
| 471 |
+
|
| 472 |
+
# Read-write separation configuration
|
| 473 |
+
export MONGODB_URI="mongodb://localhost:27017/gcli2api?readPreference=secondaryPreferred"
|
| 474 |
+
```
|
| 475 |
+
|
| 476 |
+
## 🏗️ Technical Architecture
|
| 477 |
+
|
| 478 |
+
### Core Module Description
|
| 479 |
+
|
| 480 |
+
**Authentication and Credential Management** (`src/auth.py`, `src/credential_manager.py`)
|
| 481 |
+
- OAuth 2.0 authentication flow management
|
| 482 |
+
- Multi-credential file status management and rotation
|
| 483 |
+
- Automatic failure detection and recovery
|
| 484 |
+
- JWT token generation and validation
|
| 485 |
+
|
| 486 |
+
**API Routing and Conversion** (`src/openai_router.py`, `src/gemini_router.py`, `src/openai_transfer.py`)
|
| 487 |
+
- OpenAI and Gemini format bidirectional conversion
|
| 488 |
+
- Multimodal input processing (text+images)
|
| 489 |
+
- Thinking chain content separation and processing
|
| 490 |
+
- Streaming response management
|
| 491 |
+
|
| 492 |
+
**Network and Proxy** (`src/httpx_client.py`, `src/google_chat_api.py`)
|
| 493 |
+
- Unified HTTP client management
|
| 494 |
+
- Proxy configuration and hot update support
|
| 495 |
+
- Timeout and retry strategies
|
| 496 |
+
- Asynchronous request pool management
|
| 497 |
+
|
| 498 |
+
**State Management** (`src/state_manager.py`, `src/usage_stats.py`)
|
| 499 |
+
- Atomic state operations
|
| 500 |
+
- Usage statistics and quota management
|
| 501 |
+
- File locking and concurrency safety
|
| 502 |
+
- Data persistence (TOML format)
|
| 503 |
+
|
| 504 |
+
**Task Management** (`src/task_manager.py`)
|
| 505 |
+
- Global asynchronous task lifecycle management
|
| 506 |
+
- Resource cleanup and memory management
|
| 507 |
+
- Graceful shutdown and exception handling
|
| 508 |
+
|
| 509 |
+
**Web Console** (`src/web_routes.py`)
|
| 510 |
+
- RESTful API endpoints
|
| 511 |
+
- WebSocket real-time communication
|
| 512 |
+
- Mobile device adaptation detection
|
| 513 |
+
- Batch operation support
|
| 514 |
+
|
| 515 |
+
### Advanced Feature Implementation
|
| 516 |
+
|
| 517 |
+
**Streaming Anti-truncation Mechanism** (`src/anti_truncation.py`)
|
| 518 |
+
- Response truncation pattern detection
|
| 519 |
+
- Automatic retry and state recovery
|
| 520 |
+
- Context connection management
|
| 521 |
+
|
| 522 |
+
**Format Detection and Conversion** (`src/format_detector.py`)
|
| 523 |
+
- Automatic request format detection (OpenAI vs Gemini)
|
| 524 |
+
- Seamless format conversion
|
| 525 |
+
- Parameter mapping and validation
|
| 526 |
+
|
| 527 |
+
**User Agent Simulation** (`src/utils.py`)
|
| 528 |
+
- GeminiCLI format user agent generation
|
| 529 |
+
- Platform detection and client metadata
|
| 530 |
+
- API compatibility guarantee
|
| 531 |
+
|
| 532 |
+
### Environment Variable Configuration
|
| 533 |
+
|
| 534 |
+
**Basic Configuration**
|
| 535 |
+
- `PORT`: Service port (default: 7861)
|
| 536 |
+
- `HOST`: Server listen address (default: 0.0.0.0)
|
| 537 |
+
|
| 538 |
+
**Password Configuration**
|
| 539 |
+
- `API_PASSWORD`: Chat API access password (default: inherits PASSWORD or pwd)
|
| 540 |
+
- `PANEL_PASSWORD`: Control panel access password (default: inherits PASSWORD or pwd)
|
| 541 |
+
- `PASSWORD`: Universal password, overrides the above two when set (default: pwd)
|
| 542 |
+
|
| 543 |
+
**Performance and Stability Configuration**
|
| 544 |
+
- `CALLS_PER_ROTATION`: Number of calls before each credential rotation (default: 10)
|
| 545 |
+
- `RETRY_429_ENABLED`: Enable 429 error automatic retry (default: true)
|
| 546 |
+
- `RETRY_429_MAX_RETRIES`: Maximum retry attempts for 429 errors (default: 3)
|
| 547 |
+
- `RETRY_429_INTERVAL`: Retry interval for 429 errors, in seconds (default: 1.0)
|
| 548 |
+
- `ANTI_TRUNCATION_MAX_ATTEMPTS`: Maximum retry attempts for anti-truncation (default: 3)
|
| 549 |
+
|
| 550 |
+
**Network and Proxy Configuration**
|
| 551 |
+
- `PROXY`: HTTP/HTTPS proxy address (format: `http://host:port`)
|
| 552 |
+
- `OAUTH_PROXY_URL`: OAuth authentication proxy endpoint
|
| 553 |
+
- `GOOGLEAPIS_PROXY_URL`: Google APIs proxy endpoint
|
| 554 |
+
- `METADATA_SERVICE_URL`: Metadata service proxy endpoint
|
| 555 |
+
|
| 556 |
+
**Automation Configuration**
|
| 557 |
+
- `AUTO_BAN`: Enable automatic credential banning (default: true)
|
| 558 |
+
- `AUTO_LOAD_ENV_CREDS`: Automatically load environment variable credentials at startup (default: false)
|
| 559 |
+
|
| 560 |
+
**Compatibility Configuration**
|
| 561 |
+
- `COMPATIBILITY_MODE`: Enable compatibility mode, converts system messages to user messages (default: false)
|
| 562 |
+
|
| 563 |
+
**Logging Configuration**
|
| 564 |
+
- `LOG_LEVEL`: Log level (DEBUG/INFO/WARNING/ERROR, default: INFO)
|
| 565 |
+
- `LOG_FILE`: Log file path (default: gcli2api.log)
|
| 566 |
+
|
| 567 |
+
**Storage Configuration**
|
| 568 |
+
|
| 569 |
+
**SQLite Configuration (Default)**
|
| 570 |
+
- No configuration required, automatically uses local SQLite database
|
| 571 |
+
- Database files are automatically created in the project directory
|
| 572 |
+
|
| 573 |
+
**MongoDB Configuration (Optional Cloud Storage)**
|
| 574 |
+
- `MONGODB_URI`: MongoDB connection string (enables MongoDB mode when set)
|
| 575 |
+
- `MONGODB_DATABASE`: MongoDB database name (default: gcli2api)
|
| 576 |
+
|
| 577 |
+
**Docker Usage Example**
|
| 578 |
+
```bash
|
| 579 |
+
# Using universal password
|
| 580 |
+
docker run -d --name gcli2api \
|
| 581 |
+
-e PASSWORD=mypassword \
|
| 582 |
+
-e PORT=11451 \
|
| 583 |
+
-e GOOGLE_CREDENTIALS="$(cat credential.json | base64 -w 0)" \
|
| 584 |
+
ghcr.io/su-kaka/gcli2api:latest
|
| 585 |
+
|
| 586 |
+
# Using separate passwords
|
| 587 |
+
docker run -d --name gcli2api \
|
| 588 |
+
-e API_PASSWORD=my_api_password \
|
| 589 |
+
-e PANEL_PASSWORD=my_panel_password \
|
| 590 |
+
-e PORT=11451 \
|
| 591 |
+
-e GOOGLE_CREDENTIALS="$(cat credential.json | base64 -w 0)" \
|
| 592 |
+
ghcr.io/su-kaka/gcli2api:latest
|
| 593 |
+
```
|
| 594 |
+
|
| 595 |
+
Note: When credential environment variables are set, the system will prioritize using credentials from environment variables and ignore files in the `creds` directory.
|
| 596 |
+
|
| 597 |
+
### API Usage Methods
|
| 598 |
+
|
| 599 |
+
This service supports multiple complete sets of API endpoints:
|
| 600 |
+
|
| 601 |
+
#### 1. OpenAI Compatible Endpoints (GCLI)
|
| 602 |
+
|
| 603 |
+
**Endpoint:** `/v1/chat/completions`
|
| 604 |
+
**Authentication:** `Authorization: Bearer your_api_password`
|
| 605 |
+
|
| 606 |
+
Supports two request formats with automatic detection and processing:
|
| 607 |
+
|
| 608 |
+
**OpenAI Format:**
|
| 609 |
+
```json
|
| 610 |
+
{
|
| 611 |
+
"model": "gemini-2.5-pro",
|
| 612 |
+
"messages": [
|
| 613 |
+
{"role": "system", "content": "You are a helpful assistant"},
|
| 614 |
+
{"role": "user", "content": "Hello"}
|
| 615 |
+
],
|
| 616 |
+
"temperature": 0.7,
|
| 617 |
+
"stream": true
|
| 618 |
+
}
|
| 619 |
+
```
|
| 620 |
+
|
| 621 |
+
**Gemini Native Format:**
|
| 622 |
+
```json
|
| 623 |
+
{
|
| 624 |
+
"model": "gemini-2.5-pro",
|
| 625 |
+
"contents": [
|
| 626 |
+
{"role": "user", "parts": [{"text": "Hello"}]}
|
| 627 |
+
],
|
| 628 |
+
"systemInstruction": {"parts": [{"text": "You are a helpful assistant"}]},
|
| 629 |
+
"generationConfig": {
|
| 630 |
+
"temperature": 0.7
|
| 631 |
+
}
|
| 632 |
+
}
|
| 633 |
+
```
|
| 634 |
+
|
| 635 |
+
#### 2. Gemini Native Endpoints (GCLI)
|
| 636 |
+
|
| 637 |
+
**Non-streaming Endpoint:** `/v1/models/{model}:generateContent`
|
| 638 |
+
**Streaming Endpoint:** `/v1/models/{model}:streamGenerateContent`
|
| 639 |
+
**Model List:** `/v1/models`
|
| 640 |
+
|
| 641 |
+
**Authentication Methods (choose one):**
|
| 642 |
+
- `Authorization: Bearer your_api_password`
|
| 643 |
+
- `x-goog-api-key: your_api_password`
|
| 644 |
+
- URL parameter: `?key=your_api_password`
|
| 645 |
+
|
| 646 |
+
**Request Examples:**
|
| 647 |
+
```bash
|
| 648 |
+
# Using x-goog-api-key header
|
| 649 |
+
curl -X POST "http://127.0.0.1:7861/v1/models/gemini-2.5-pro:generateContent" \
|
| 650 |
+
-H "x-goog-api-key: your_api_password" \
|
| 651 |
+
-H "Content-Type: application/json" \
|
| 652 |
+
-d '{
|
| 653 |
+
"contents": [
|
| 654 |
+
{"role": "user", "parts": [{"text": "Hello"}]}
|
| 655 |
+
]
|
| 656 |
+
}'
|
| 657 |
+
|
| 658 |
+
# Using URL parameter
|
| 659 |
+
curl -X POST "http://127.0.0.1:7861/v1/models/gemini-2.5-pro:streamGenerateContent?key=your_api_password" \
|
| 660 |
+
-H "Content-Type: application/json" \
|
| 661 |
+
-d '{
|
| 662 |
+
"contents": [
|
| 663 |
+
{"role": "user", "parts": [{"text": "Hello"}]}
|
| 664 |
+
]
|
| 665 |
+
}'
|
| 666 |
+
```
|
| 667 |
+
|
| 668 |
+
#### 3. Claude API Format Endpoints
|
| 669 |
+
|
| 670 |
+
**Endpoint:** `/v1/messages`
|
| 671 |
+
**Authentication:** `x-api-key: your_api_password` or `Authorization: Bearer your_api_password`
|
| 672 |
+
|
| 673 |
+
**Request Example:**
|
| 674 |
+
```bash
|
| 675 |
+
curl -X POST "http://127.0.0.1:7861/v1/messages" \
|
| 676 |
+
-H "x-api-key: your_api_password" \
|
| 677 |
+
-H "anthropic-version: 2023-06-01" \
|
| 678 |
+
-H "Content-Type: application/json" \
|
| 679 |
+
-d '{
|
| 680 |
+
"model": "gemini-2.5-pro",
|
| 681 |
+
"max_tokens": 1024,
|
| 682 |
+
"messages": [
|
| 683 |
+
{"role": "user", "content": "Hello, Claude!"}
|
| 684 |
+
]
|
| 685 |
+
}'
|
| 686 |
+
```
|
| 687 |
+
|
| 688 |
+
**Support for system parameter:**
|
| 689 |
+
```json
|
| 690 |
+
{
|
| 691 |
+
"model": "gemini-2.5-pro",
|
| 692 |
+
"max_tokens": 1024,
|
| 693 |
+
"system": "You are a helpful assistant",
|
| 694 |
+
"messages": [
|
| 695 |
+
{"role": "user", "content": "Hello"}
|
| 696 |
+
]
|
| 697 |
+
}
|
| 698 |
+
```
|
| 699 |
+
|
| 700 |
+
**Notes:**
|
| 701 |
+
- Fully compatible with Claude API format specification
|
| 702 |
+
- Automatically converts to Gemini format for backend calls
|
| 703 |
+
- Supports all Claude standard parameters
|
| 704 |
+
- Response format follows Claude API specification
|
| 705 |
+
|
| 706 |
+
#### 4. Antigravity API Endpoints
|
| 707 |
+
|
| 708 |
+
**Supports three formats: OpenAI, Gemini, and Claude**
|
| 709 |
+
|
| 710 |
+
##### Antigravity OpenAI Format Endpoints
|
| 711 |
+
|
| 712 |
+
**Endpoint:** `/antigravity/v1/chat/completions`
|
| 713 |
+
**Authentication:** `Authorization: Bearer your_api_password`
|
| 714 |
+
|
| 715 |
+
**Request Example:**
|
| 716 |
+
```bash
|
| 717 |
+
curl -X POST "http://127.0.0.1:7861/antigravity/v1/chat/completions" \
|
| 718 |
+
-H "Authorization: Bearer your_api_password" \
|
| 719 |
+
-H "Content-Type: application/json" \
|
| 720 |
+
-d '{
|
| 721 |
+
"model": "claude-sonnet-4-5",
|
| 722 |
+
"messages": [
|
| 723 |
+
{"role": "user", "content": "Hello"}
|
| 724 |
+
],
|
| 725 |
+
"stream": true
|
| 726 |
+
}'
|
| 727 |
+
```
|
| 728 |
+
|
| 729 |
+
##### Antigravity Gemini Format Endpoints
|
| 730 |
+
|
| 731 |
+
**Non-streaming Endpoint:** `/antigravity/v1/models/{model}:generateContent`
|
| 732 |
+
**Streaming Endpoint:** `/antigravity/v1/models/{model}:streamGenerateContent`
|
| 733 |
+
|
| 734 |
+
**Authentication Methods (choose one):**
|
| 735 |
+
- `Authorization: Bearer your_api_password`
|
| 736 |
+
- `x-goog-api-key: your_api_password`
|
| 737 |
+
- URL parameter: `?key=your_api_password`
|
| 738 |
+
|
| 739 |
+
**Request Examples:**
|
| 740 |
+
```bash
|
| 741 |
+
# Gemini format non-streaming request
|
| 742 |
+
curl -X POST "http://127.0.0.1:7861/antigravity/v1/models/claude-sonnet-4-5:generateContent" \
|
| 743 |
+
-H "x-goog-api-key: your_api_password" \
|
| 744 |
+
-H "Content-Type: application/json" \
|
| 745 |
+
-d '{
|
| 746 |
+
"contents": [
|
| 747 |
+
{"role": "user", "parts": [{"text": "Hello"}]}
|
| 748 |
+
],
|
| 749 |
+
"generationConfig": {
|
| 750 |
+
"temperature": 0.7
|
| 751 |
+
}
|
| 752 |
+
}'
|
| 753 |
+
|
| 754 |
+
# Gemini format streaming request
|
| 755 |
+
curl -X POST "http://127.0.0.1:7861/antigravity/v1/models/gemini-2.5-flash:streamGenerateContent?key=your_api_password" \
|
| 756 |
+
-H "Content-Type: application/json" \
|
| 757 |
+
-d '{
|
| 758 |
+
"contents": [
|
| 759 |
+
{"role": "user", "parts": [{"text": "Hello"}]}
|
| 760 |
+
]
|
| 761 |
+
}'
|
| 762 |
+
```
|
| 763 |
+
|
| 764 |
+
##### Antigravity Claude Format Endpoints
|
| 765 |
+
|
| 766 |
+
**Endpoint:** `/antigravity/v1/messages`
|
| 767 |
+
**Authentication:** `x-api-key: your_api_password`
|
| 768 |
+
|
| 769 |
+
**Request Example:**
|
| 770 |
+
```bash
|
| 771 |
+
curl -X POST "http://127.0.0.1:7861/antigravity/v1/messages" \
|
| 772 |
+
-H "x-api-key: your_api_password" \
|
| 773 |
+
-H "anthropic-version: 2023-06-01" \
|
| 774 |
+
-H "Content-Type: application/json" \
|
| 775 |
+
-d '{
|
| 776 |
+
"model": "claude-sonnet-4-5",
|
| 777 |
+
"max_tokens": 1024,
|
| 778 |
+
"messages": [
|
| 779 |
+
{"role": "user", "content": "Hello"}
|
| 780 |
+
]
|
| 781 |
+
}'
|
| 782 |
+
```
|
| 783 |
+
|
| 784 |
+
**Supported Antigravity Models:**
|
| 785 |
+
- Claude series: `claude-sonnet-4-5`, `claude-opus-4-5`, etc.
|
| 786 |
+
- Gemini series: `gemini-2.5-flash`, `gemini-2.5-pro`, etc.
|
| 787 |
+
- Automatically supports thinking models
|
| 788 |
+
|
| 789 |
+
**Gemini Native Example:**
|
| 790 |
+
```python
|
| 791 |
+
from io import BytesIO
|
| 792 |
+
from PIL import Image
|
| 793 |
+
from google.genai import Client
|
| 794 |
+
from google.genai.types import HttpOptions
|
| 795 |
+
from google.genai import types
|
| 796 |
+
# The client gets the API key from the environment variable `GEMINI_API_KEY`.
|
| 797 |
+
|
| 798 |
+
client = Client(
|
| 799 |
+
api_key="pwd",
|
| 800 |
+
http_options=HttpOptions(base_url="http://127.0.0.1:7861"),
|
| 801 |
+
)
|
| 802 |
+
|
| 803 |
+
prompt = (
|
| 804 |
+
"""
|
| 805 |
+
Draw a cat
|
| 806 |
+
"""
|
| 807 |
+
)
|
| 808 |
+
|
| 809 |
+
response = client.models.generate_content(
|
| 810 |
+
model="gemini-2.5-flash-image",
|
| 811 |
+
contents=[prompt],
|
| 812 |
+
config=types.GenerateContentConfig(
|
| 813 |
+
image_config=types.ImageConfig(
|
| 814 |
+
aspect_ratio="16:9",
|
| 815 |
+
)
|
| 816 |
+
)
|
| 817 |
+
)
|
| 818 |
+
for part in response.candidates[0].content.parts:
|
| 819 |
+
if part.text is not None:
|
| 820 |
+
print(part.text)
|
| 821 |
+
elif part.inline_data is not None:
|
| 822 |
+
image = Image.open(BytesIO(part.inline_data.data))
|
| 823 |
+
image.save("generated_image.png")
|
| 824 |
+
|
| 825 |
+
```
|
| 826 |
+
|
| 827 |
+
**Notes:**
|
| 828 |
+
- OpenAI endpoints return OpenAI-compatible format
|
| 829 |
+
- Gemini endpoints return Gemini native format
|
| 830 |
+
- Claude endpoints return Claude-compatible format
|
| 831 |
+
- All endpoints use the same API password
|
| 832 |
+
|
| 833 |
+
## 📋 Complete API Reference
|
| 834 |
+
|
| 835 |
+
### Web Console API
|
| 836 |
+
|
| 837 |
+
**Authentication Endpoints**
|
| 838 |
+
- `POST /auth/login` - User login
|
| 839 |
+
- `POST /auth/start` - Start GCLI OAuth authentication
|
| 840 |
+
- `POST /auth/antigravity/start` - Start Antigravity OAuth authentication
|
| 841 |
+
- `POST /auth/callback` - Handle OAuth callback
|
| 842 |
+
- `GET /auth/status/{project_id}` - Check authentication status
|
| 843 |
+
- `GET /auth/antigravity/credentials` - Get Antigravity credentials
|
| 844 |
+
|
| 845 |
+
**GCLI Credential Management Endpoints**
|
| 846 |
+
- `GET /creds/status` - Get all GCLI credential statuses
|
| 847 |
+
- `POST /creds/action` - Single GCLI credential operation (enable/disable/delete)
|
| 848 |
+
- `POST /creds/batch-action` - Batch GCLI credential operations
|
| 849 |
+
- `POST /auth/upload` - Batch upload GCLI credential files (supports ZIP)
|
| 850 |
+
- `GET /creds/download/{filename}` - Download GCLI credential file
|
| 851 |
+
- `GET /creds/download-all` - Package download all GCLI credentials
|
| 852 |
+
- `POST /creds/fetch-email/{filename}` - Get GCLI user email
|
| 853 |
+
- `POST /creds/refresh-all-emails` - Batch refresh GCLI user emails
|
| 854 |
+
|
| 855 |
+
**Antigravity Credential Management Endpoints**
|
| 856 |
+
- `GET /antigravity/creds/status` - Get all Antigravity credential statuses
|
| 857 |
+
- `POST /antigravity/creds/action` - Single Antigravity credential operation (enable/disable/delete)
|
| 858 |
+
- `POST /antigravity/creds/batch-action` - Batch Antigravity credential operations
|
| 859 |
+
- `POST /antigravity/auth/upload` - Batch upload Antigravity credential files (supports ZIP)
|
| 860 |
+
- `GET /antigravity/creds/download/{filename}` - Download Antigravity credential file
|
| 861 |
+
- `GET /antigravity/creds/download-all` - Package download all Antigravity credentials
|
| 862 |
+
- `POST /antigravity/creds/fetch-email/{filename}` - Get Antigravity user email
|
| 863 |
+
- `POST /antigravity/creds/refresh-all-emails` - Batch refresh Antigravity user emails
|
| 864 |
+
|
| 865 |
+
**Configuration Management Endpoints**
|
| 866 |
+
- `GET /config/get` - Get current configuration
|
| 867 |
+
- `POST /config/save` - Save configuration
|
| 868 |
+
|
| 869 |
+
**Environment Variable Credential Endpoints**
|
| 870 |
+
- `POST /auth/load-env-creds` - Load environment variable credentials
|
| 871 |
+
- `DELETE /auth/env-creds` - Clear environment variable credentials
|
| 872 |
+
- `GET /auth/env-creds-status` - Get environment variable credential status
|
| 873 |
+
|
| 874 |
+
**Log Management Endpoints**
|
| 875 |
+
- `POST /auth/logs/clear` - Clear logs
|
| 876 |
+
- `GET /auth/logs/download` - Download log file
|
| 877 |
+
- `WebSocket /auth/logs/stream` - Real-time log stream
|
| 878 |
+
|
| 879 |
+
**Usage Statistics Endpoints**
|
| 880 |
+
- `GET /usage/stats` - Get usage statistics
|
| 881 |
+
- `GET /usage/aggregated` - Get aggregated statistics
|
| 882 |
+
- `POST /usage/update-limits` - Update usage limits
|
| 883 |
+
- `POST /usage/reset` - Reset usage statistics
|
| 884 |
+
|
| 885 |
+
### Chat API Features
|
| 886 |
+
|
| 887 |
+
**Multimodal Support**
|
| 888 |
+
```json
|
| 889 |
+
{
|
| 890 |
+
"model": "gemini-2.5-pro",
|
| 891 |
+
"messages": [
|
| 892 |
+
{
|
| 893 |
+
"role": "user",
|
| 894 |
+
"content": [
|
| 895 |
+
{"type": "text", "text": "Describe this image"},
|
| 896 |
+
{
|
| 897 |
+
"type": "image_url",
|
| 898 |
+
"image_url": {
|
| 899 |
+
"url": "data:image/jpeg;base64,/9j/4AAQSkZJRgABA..."
|
| 900 |
+
}
|
| 901 |
+
}
|
| 902 |
+
]
|
| 903 |
+
}
|
| 904 |
+
]
|
| 905 |
+
}
|
| 906 |
+
```
|
| 907 |
+
|
| 908 |
+
**Thinking Mode Support**
|
| 909 |
+
```json
|
| 910 |
+
{
|
| 911 |
+
"model": "gemini-2.5-pro-maxthinking",
|
| 912 |
+
"messages": [
|
| 913 |
+
{"role": "user", "content": "Complex math problem"}
|
| 914 |
+
]
|
| 915 |
+
}
|
| 916 |
+
```
|
| 917 |
+
|
| 918 |
+
Response will include separated thinking content:
|
| 919 |
+
```json
|
| 920 |
+
{
|
| 921 |
+
"choices": [{
|
| 922 |
+
"message": {
|
| 923 |
+
"role": "assistant",
|
| 924 |
+
"content": "Final answer",
|
| 925 |
+
"reasoning_content": "Detailed thought process..."
|
| 926 |
+
}
|
| 927 |
+
}]
|
| 928 |
+
}
|
| 929 |
+
```
|
| 930 |
+
|
| 931 |
+
**Streaming Anti-truncation Usage**
|
| 932 |
+
```json
|
| 933 |
+
{
|
| 934 |
+
"model": "流式抗截断/gemini-2.5-pro",
|
| 935 |
+
"messages": [
|
| 936 |
+
{"role": "user", "content": "Write a long article"}
|
| 937 |
+
],
|
| 938 |
+
"stream": true
|
| 939 |
+
}
|
| 940 |
+
```
|
| 941 |
+
|
| 942 |
+
**Compatibility Mode**
|
| 943 |
+
```bash
|
| 944 |
+
# Enable compatibility mode
|
| 945 |
+
export COMPATIBILITY_MODE=true
|
| 946 |
+
```
|
| 947 |
+
In this mode, all `system` messages are converted to `user` messages, improving compatibility with certain clients.
|
| 948 |
+
|
| 949 |
+
---
|
| 950 |
+
|
| 951 |
+
## License and Disclaimer
|
| 952 |
+
|
| 953 |
+
This project is for learning and research purposes only. Using this project indicates that you agree to:
|
| 954 |
+
- Not use this project for any commercial purposes
|
| 955 |
+
- Bear all risks and responsibilities of using this project
|
| 956 |
+
- Comply with relevant terms of service and legal regulations
|
| 957 |
+
|
| 958 |
+
The project authors are not responsible for any direct or indirect losses arising from the use of this project.
|
docs/qq群.jpg
ADDED
|
Git LFS Details
|
front/common.js
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
front/control_panel.html
ADDED
|
@@ -0,0 +1,2092 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="zh-CN">
|
| 3 |
+
|
| 4 |
+
<head>
|
| 5 |
+
<meta charset="UTF-8">
|
| 6 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 7 |
+
<title>GCLI2API 控制面板</title>
|
| 8 |
+
<style>
|
| 9 |
+
body {
|
| 10 |
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
|
| 11 |
+
max-width: 1000px;
|
| 12 |
+
margin: 0 auto;
|
| 13 |
+
padding: 20px;
|
| 14 |
+
background-color: #f8f9fa;
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
.container {
|
| 18 |
+
background-color: white;
|
| 19 |
+
padding: 30px;
|
| 20 |
+
border-radius: 10px;
|
| 21 |
+
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
| 22 |
+
box-sizing: border-box;
|
| 23 |
+
overflow-x: auto;
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
h1 {
|
| 27 |
+
color: #333;
|
| 28 |
+
text-align: center;
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
.form-group {
|
| 32 |
+
margin-bottom: 20px;
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
label {
|
| 36 |
+
display: block;
|
| 37 |
+
margin-bottom: 5px;
|
| 38 |
+
font-weight: bold;
|
| 39 |
+
color: #555;
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
input[type="text"] {
|
| 43 |
+
width: 100%;
|
| 44 |
+
padding: 10px;
|
| 45 |
+
border: 2px solid #ddd;
|
| 46 |
+
border-radius: 5px;
|
| 47 |
+
font-size: 16px;
|
| 48 |
+
box-sizing: border-box;
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
input[type="text"]:focus {
|
| 52 |
+
border-color: #4285f4;
|
| 53 |
+
outline: none;
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
.btn {
|
| 57 |
+
background-color: #4285f4;
|
| 58 |
+
color: white;
|
| 59 |
+
padding: 12px 30px;
|
| 60 |
+
border: none;
|
| 61 |
+
border-radius: 5px;
|
| 62 |
+
font-size: 16px;
|
| 63 |
+
cursor: pointer;
|
| 64 |
+
width: 100%;
|
| 65 |
+
margin-bottom: 10px;
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
.btn:hover {
|
| 69 |
+
background-color: #3367d6;
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
.btn:disabled {
|
| 73 |
+
background-color: #ccc;
|
| 74 |
+
cursor: not-allowed;
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
.auth-url {
|
| 78 |
+
background-color: #f8f9fa;
|
| 79 |
+
border: 1px solid #e1e4e8;
|
| 80 |
+
border-radius: 5px;
|
| 81 |
+
padding: 15px;
|
| 82 |
+
margin: 20px 0;
|
| 83 |
+
word-break: break-all;
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
.auth-url a {
|
| 87 |
+
color: #4285f4;
|
| 88 |
+
text-decoration: none;
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
.auth-url a:hover {
|
| 92 |
+
text-decoration: underline;
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
.credentials {
|
| 96 |
+
background-color: #f0f8ff;
|
| 97 |
+
border: 1px solid #b0d4ff;
|
| 98 |
+
border-radius: 5px;
|
| 99 |
+
padding: 15px;
|
| 100 |
+
margin: 20px 0;
|
| 101 |
+
font-family: monospace;
|
| 102 |
+
font-size: 12px;
|
| 103 |
+
white-space: pre-wrap;
|
| 104 |
+
word-break: break-all;
|
| 105 |
+
max-height: 400px;
|
| 106 |
+
overflow-y: auto;
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
/* Toast 固定定位在右上角 */
|
| 110 |
+
#statusSection {
|
| 111 |
+
position: fixed;
|
| 112 |
+
top: 20px;
|
| 113 |
+
right: 20px;
|
| 114 |
+
left: auto;
|
| 115 |
+
transform: none;
|
| 116 |
+
z-index: 9999;
|
| 117 |
+
width: auto;
|
| 118 |
+
max-width: 400px;
|
| 119 |
+
min-width: 250px;
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
/* Toast 专用样式 - 只在 #statusSection 内生效 */
|
| 123 |
+
#statusSection .status {
|
| 124 |
+
padding: 12px 20px;
|
| 125 |
+
border-radius: 8px;
|
| 126 |
+
margin: 0;
|
| 127 |
+
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
|
| 128 |
+
opacity: 0;
|
| 129 |
+
transform: translateX(100%);
|
| 130 |
+
transition: opacity 0.3s ease, transform 0.3s ease;
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
#statusSection .status.show {
|
| 134 |
+
opacity: 1;
|
| 135 |
+
transform: translateX(0);
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
#statusSection .status.fade-out {
|
| 139 |
+
opacity: 0;
|
| 140 |
+
transform: translateX(100%);
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
#statusSection .status.success {
|
| 144 |
+
background-color: #28a745;
|
| 145 |
+
border: none;
|
| 146 |
+
color: white;
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
#statusSection .status.error {
|
| 150 |
+
background-color: #dc3545;
|
| 151 |
+
border: none;
|
| 152 |
+
color: white;
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
#statusSection .status.warning {
|
| 156 |
+
background-color: #ffc107;
|
| 157 |
+
border: none;
|
| 158 |
+
color: #212529;
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
#statusSection .status.info {
|
| 162 |
+
background-color: #17a2b8;
|
| 163 |
+
border: none;
|
| 164 |
+
color: white;
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
/* 页面内嵌的 status 样式 - 保持原有风格 */
|
| 168 |
+
.status {
|
| 169 |
+
padding: 10px;
|
| 170 |
+
border-radius: 5px;
|
| 171 |
+
margin: 10px 0;
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
.status.success {
|
| 175 |
+
background-color: #d4edda;
|
| 176 |
+
border: 1px solid #c3e6cb;
|
| 177 |
+
color: #155724;
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
.status.error {
|
| 181 |
+
background-color: #f8d7da;
|
| 182 |
+
border: 1px solid #f5c6cb;
|
| 183 |
+
color: #721c24;
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
.status.info {
|
| 187 |
+
background-color: #d1ecf1;
|
| 188 |
+
border: 1px solid #bee5eb;
|
| 189 |
+
color: #0c5460;
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
.hidden {
|
| 193 |
+
display: none;
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
.loading {
|
| 197 |
+
text-align: center;
|
| 198 |
+
color: #666;
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
.login-form {
|
| 202 |
+
text-align: center;
|
| 203 |
+
padding: 50px 0;
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
.login-form input[type="password"] {
|
| 207 |
+
width: 300px;
|
| 208 |
+
padding: 12px;
|
| 209 |
+
border: 2px solid #ddd;
|
| 210 |
+
border-radius: 5px;
|
| 211 |
+
font-size: 16px;
|
| 212 |
+
margin-bottom: 20px;
|
| 213 |
+
box-sizing: border-box;
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
.tabs {
|
| 217 |
+
display: inline-flex;
|
| 218 |
+
background: linear-gradient(145deg, #f5f5f7, #e8e8ed);
|
| 219 |
+
padding: 6px;
|
| 220 |
+
border-radius: 14px;
|
| 221 |
+
margin-bottom: 25px;
|
| 222 |
+
border-bottom: none;
|
| 223 |
+
gap: 3px;
|
| 224 |
+
overflow-x: auto;
|
| 225 |
+
max-width: 100%;
|
| 226 |
+
user-select: none;
|
| 227 |
+
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.06),
|
| 228 |
+
0 1px 2px rgba(255, 255, 255, 0.9);
|
| 229 |
+
position: relative;
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
/* 滑块指示器 */
|
| 233 |
+
.tab-slider {
|
| 234 |
+
position: absolute;
|
| 235 |
+
top: 6px;
|
| 236 |
+
left: 0;
|
| 237 |
+
right: 0;
|
| 238 |
+
height: calc(100% - 12px);
|
| 239 |
+
background: white;
|
| 240 |
+
border-radius: 10px;
|
| 241 |
+
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1),
|
| 242 |
+
0 1px 3px rgba(0, 0, 0, 0.06),
|
| 243 |
+
inset 0 -1px 0 rgba(0, 0, 0, 0.02);
|
| 244 |
+
transition: left 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
| 245 |
+
right 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
| 246 |
+
z-index: 0;
|
| 247 |
+
pointer-events: none;
|
| 248 |
+
}
|
| 249 |
+
|
| 250 |
+
/* 滑块微光效果 */
|
| 251 |
+
.tab-slider::after {
|
| 252 |
+
content: '';
|
| 253 |
+
position: absolute;
|
| 254 |
+
top: 0;
|
| 255 |
+
left: 0;
|
| 256 |
+
right: 0;
|
| 257 |
+
height: 50%;
|
| 258 |
+
background: linear-gradient(180deg,
|
| 259 |
+
rgba(255, 255, 255, 0.8) 0%,
|
| 260 |
+
rgba(255, 255, 255, 0) 100%);
|
| 261 |
+
border-radius: 10px 10px 0 0;
|
| 262 |
+
pointer-events: none;
|
| 263 |
+
opacity: 0.5;
|
| 264 |
+
}
|
| 265 |
+
|
| 266 |
+
.tabs::-webkit-scrollbar {
|
| 267 |
+
height: 4px;
|
| 268 |
+
}
|
| 269 |
+
|
| 270 |
+
.tabs::-webkit-scrollbar-track {
|
| 271 |
+
background: transparent;
|
| 272 |
+
}
|
| 273 |
+
|
| 274 |
+
.tabs::-webkit-scrollbar-thumb {
|
| 275 |
+
background: rgba(0, 0, 0, 0.15);
|
| 276 |
+
border-radius: 2px;
|
| 277 |
+
}
|
| 278 |
+
|
| 279 |
+
.tabs::-webkit-scrollbar-thumb:hover {
|
| 280 |
+
background: rgba(0, 0, 0, 0.25);
|
| 281 |
+
}
|
| 282 |
+
|
| 283 |
+
.tab {
|
| 284 |
+
padding: 10px 18px;
|
| 285 |
+
cursor: pointer;
|
| 286 |
+
border: none;
|
| 287 |
+
background: transparent;
|
| 288 |
+
border-radius: 10px;
|
| 289 |
+
color: #666;
|
| 290 |
+
font-size: 14px;
|
| 291 |
+
font-weight: 450;
|
| 292 |
+
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
| 293 |
+
white-space: nowrap;
|
| 294 |
+
position: relative;
|
| 295 |
+
overflow: hidden;
|
| 296 |
+
z-index: 1;
|
| 297 |
+
}
|
| 298 |
+
|
| 299 |
+
/* 点击波纹效果 */
|
| 300 |
+
.tab::before {
|
| 301 |
+
content: '';
|
| 302 |
+
position: absolute;
|
| 303 |
+
top: 50%;
|
| 304 |
+
left: 50%;
|
| 305 |
+
width: 0;
|
| 306 |
+
height: 0;
|
| 307 |
+
border-radius: 50%;
|
| 308 |
+
background: rgba(66, 133, 244, 0.15);
|
| 309 |
+
transform: translate(-50%, -50%);
|
| 310 |
+
transition: width 0.4s ease, height 0.4s ease;
|
| 311 |
+
z-index: -1;
|
| 312 |
+
}
|
| 313 |
+
|
| 314 |
+
.tab:active::before {
|
| 315 |
+
width: 200%;
|
| 316 |
+
height: 200%;
|
| 317 |
+
}
|
| 318 |
+
|
| 319 |
+
.tab.active {
|
| 320 |
+
color: #1a1a1a;
|
| 321 |
+
font-weight: 550;
|
| 322 |
+
transform: translateY(0);
|
| 323 |
+
/* 背景和阴影由滑块提供 */
|
| 324 |
+
}
|
| 325 |
+
|
| 326 |
+
.tab:hover:not(.active) {
|
| 327 |
+
background: rgba(255, 255, 255, 0.6);
|
| 328 |
+
color: #333;
|
| 329 |
+
transform: translateY(-1px);
|
| 330 |
+
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.04);
|
| 331 |
+
}
|
| 332 |
+
|
| 333 |
+
/* 按压效果 */
|
| 334 |
+
.tab:active {
|
| 335 |
+
transform: scale(0.97) translateY(0);
|
| 336 |
+
transition: transform 0.1s ease;
|
| 337 |
+
}
|
| 338 |
+
|
| 339 |
+
.tab.active:active {
|
| 340 |
+
transform: scale(0.98);
|
| 341 |
+
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08),
|
| 342 |
+
inset 0 1px 2px rgba(0, 0, 0, 0.02);
|
| 343 |
+
}
|
| 344 |
+
|
| 345 |
+
/* 焦点状态(键盘导航) */
|
| 346 |
+
.tab:focus {
|
| 347 |
+
outline: none;
|
| 348 |
+
}
|
| 349 |
+
|
| 350 |
+
.tab:focus-visible {
|
| 351 |
+
outline: 2px solid rgba(66, 133, 244, 0.5);
|
| 352 |
+
outline-offset: 2px;
|
| 353 |
+
}
|
| 354 |
+
|
| 355 |
+
.tab-content {
|
| 356 |
+
display: none;
|
| 357 |
+
width: 100%;
|
| 358 |
+
box-sizing: border-box;
|
| 359 |
+
/* 动画由 JavaScript 控制,避免冲突 */
|
| 360 |
+
}
|
| 361 |
+
|
| 362 |
+
.tab-content.active {
|
| 363 |
+
display: block;
|
| 364 |
+
}
|
| 365 |
+
|
| 366 |
+
.upload-area {
|
| 367 |
+
border: 2px dashed #ddd;
|
| 368 |
+
border-radius: 5px;
|
| 369 |
+
padding: 40px;
|
| 370 |
+
text-align: center;
|
| 371 |
+
background-color: #fafafa;
|
| 372 |
+
margin: 20px 0;
|
| 373 |
+
cursor: pointer;
|
| 374 |
+
transition: border-color 0.3s;
|
| 375 |
+
}
|
| 376 |
+
|
| 377 |
+
.upload-area:hover {
|
| 378 |
+
border-color: #4285f4;
|
| 379 |
+
}
|
| 380 |
+
|
| 381 |
+
.upload-area.dragover {
|
| 382 |
+
border-color: #4285f4;
|
| 383 |
+
background-color: #f0f8ff;
|
| 384 |
+
}
|
| 385 |
+
|
| 386 |
+
.file-list {
|
| 387 |
+
margin: 20px 0;
|
| 388 |
+
}
|
| 389 |
+
|
| 390 |
+
.file-item {
|
| 391 |
+
background-color: #f8f9fa;
|
| 392 |
+
border: 1px solid #e1e4e8;
|
| 393 |
+
border-radius: 3px;
|
| 394 |
+
padding: 10px;
|
| 395 |
+
margin: 5px 0;
|
| 396 |
+
display: flex;
|
| 397 |
+
justify-content: space-between;
|
| 398 |
+
align-items: center;
|
| 399 |
+
}
|
| 400 |
+
|
| 401 |
+
.file-item .file-name {
|
| 402 |
+
font-family: monospace;
|
| 403 |
+
color: #333;
|
| 404 |
+
}
|
| 405 |
+
|
| 406 |
+
.file-item .file-size {
|
| 407 |
+
color: #666;
|
| 408 |
+
font-size: 12px;
|
| 409 |
+
}
|
| 410 |
+
|
| 411 |
+
.file-item .remove-btn {
|
| 412 |
+
background: #dc3545;
|
| 413 |
+
color: white;
|
| 414 |
+
border: none;
|
| 415 |
+
border-radius: 3px;
|
| 416 |
+
padding: 2px 8px;
|
| 417 |
+
cursor: pointer;
|
| 418 |
+
font-size: 12px;
|
| 419 |
+
}
|
| 420 |
+
|
| 421 |
+
.upload-progress {
|
| 422 |
+
background-color: #f8f9fa;
|
| 423 |
+
border: 1px solid #e1e4e8;
|
| 424 |
+
border-radius: 5px;
|
| 425 |
+
padding: 15px;
|
| 426 |
+
margin: 20px 0;
|
| 427 |
+
}
|
| 428 |
+
|
| 429 |
+
.progress-bar {
|
| 430 |
+
width: 100%;
|
| 431 |
+
height: 20px;
|
| 432 |
+
background-color: #e9ecef;
|
| 433 |
+
border-radius: 10px;
|
| 434 |
+
overflow: hidden;
|
| 435 |
+
margin: 10px 0;
|
| 436 |
+
}
|
| 437 |
+
|
| 438 |
+
.progress-fill {
|
| 439 |
+
height: 100%;
|
| 440 |
+
background-color: #28a745;
|
| 441 |
+
transition: width 0.3s ease;
|
| 442 |
+
}
|
| 443 |
+
|
| 444 |
+
/* 文件管理样式 */
|
| 445 |
+
.cred-card {
|
| 446 |
+
background-color: #f8f9fa;
|
| 447 |
+
border: 1px solid #e1e4e8;
|
| 448 |
+
border-radius: 8px;
|
| 449 |
+
padding: 15px;
|
| 450 |
+
margin: 10px 0;
|
| 451 |
+
position: relative;
|
| 452 |
+
width: 100%;
|
| 453 |
+
box-sizing: border-box;
|
| 454 |
+
}
|
| 455 |
+
|
| 456 |
+
.cred-card.disabled {
|
| 457 |
+
background-color: #f5f5f5;
|
| 458 |
+
border-color: #ccc;
|
| 459 |
+
opacity: 0.7;
|
| 460 |
+
}
|
| 461 |
+
|
| 462 |
+
.cred-header {
|
| 463 |
+
display: flex;
|
| 464 |
+
justify-content: space-between;
|
| 465 |
+
align-items: flex-start;
|
| 466 |
+
margin-bottom: 10px;
|
| 467 |
+
gap: 20px;
|
| 468 |
+
}
|
| 469 |
+
|
| 470 |
+
.cred-filename {
|
| 471 |
+
font-family: monospace;
|
| 472 |
+
font-weight: bold;
|
| 473 |
+
color: #333;
|
| 474 |
+
font-size: 14px;
|
| 475 |
+
white-space: nowrap;
|
| 476 |
+
overflow: hidden;
|
| 477 |
+
text-overflow: ellipsis;
|
| 478 |
+
min-width: 300px;
|
| 479 |
+
}
|
| 480 |
+
|
| 481 |
+
.cred-status {
|
| 482 |
+
display: flex;
|
| 483 |
+
gap: 5px;
|
| 484 |
+
}
|
| 485 |
+
|
| 486 |
+
.status-badge {
|
| 487 |
+
padding: 2px 8px;
|
| 488 |
+
border-radius: 12px;
|
| 489 |
+
font-size: 12px;
|
| 490 |
+
color: white;
|
| 491 |
+
}
|
| 492 |
+
|
| 493 |
+
.status-badge.enabled {
|
| 494 |
+
background-color: #28a745;
|
| 495 |
+
}
|
| 496 |
+
|
| 497 |
+
.status-badge.disabled {
|
| 498 |
+
background-color: #6c757d;
|
| 499 |
+
}
|
| 500 |
+
|
| 501 |
+
.error-codes {
|
| 502 |
+
background-color: #f8d7da;
|
| 503 |
+
color: #721c24;
|
| 504 |
+
padding: 2px 8px;
|
| 505 |
+
border-radius: 12px;
|
| 506 |
+
font-size: 12px;
|
| 507 |
+
}
|
| 508 |
+
|
| 509 |
+
.cooldown-badge {
|
| 510 |
+
background-color: #ffc107;
|
| 511 |
+
color: #856404;
|
| 512 |
+
padding: 2px 8px;
|
| 513 |
+
border-radius: 12px;
|
| 514 |
+
font-size: 12px;
|
| 515 |
+
font-weight: bold;
|
| 516 |
+
}
|
| 517 |
+
|
| 518 |
+
.cooldown-badge.ready {
|
| 519 |
+
background-color: #28a745;
|
| 520 |
+
color: white;
|
| 521 |
+
}
|
| 522 |
+
|
| 523 |
+
.model-cooldown-details {
|
| 524 |
+
background-color: #e3f2fd;
|
| 525 |
+
border: 1px solid #90caf9;
|
| 526 |
+
border-radius: 4px;
|
| 527 |
+
padding: 8px;
|
| 528 |
+
margin-top: 8px;
|
| 529 |
+
font-size: 11px;
|
| 530 |
+
color: #1976d2;
|
| 531 |
+
}
|
| 532 |
+
|
| 533 |
+
.model-cooldown-item {
|
| 534 |
+
display: inline-block;
|
| 535 |
+
background-color: #64b5f6;
|
| 536 |
+
color: white;
|
| 537 |
+
padding: 2px 6px;
|
| 538 |
+
border-radius: 10px;
|
| 539 |
+
margin: 2px;
|
| 540 |
+
font-size: 10px;
|
| 541 |
+
}
|
| 542 |
+
|
| 543 |
+
.cred-actions {
|
| 544 |
+
display: flex;
|
| 545 |
+
gap: 5px;
|
| 546 |
+
flex-wrap: wrap;
|
| 547 |
+
}
|
| 548 |
+
|
| 549 |
+
.cred-btn {
|
| 550 |
+
padding: 4px 12px;
|
| 551 |
+
border: none;
|
| 552 |
+
border-radius: 4px;
|
| 553 |
+
font-size: 12px;
|
| 554 |
+
cursor: pointer;
|
| 555 |
+
transition: background-color 0.2s;
|
| 556 |
+
}
|
| 557 |
+
|
| 558 |
+
.cred-btn.enable {
|
| 559 |
+
background-color: #28a745;
|
| 560 |
+
color: white;
|
| 561 |
+
}
|
| 562 |
+
|
| 563 |
+
.cred-btn.disable {
|
| 564 |
+
background-color: #6c757d;
|
| 565 |
+
color: white;
|
| 566 |
+
}
|
| 567 |
+
|
| 568 |
+
.cred-btn.delete {
|
| 569 |
+
background-color: #dc3545;
|
| 570 |
+
color: white;
|
| 571 |
+
}
|
| 572 |
+
|
| 573 |
+
.cred-btn.download {
|
| 574 |
+
background-color: #007bff;
|
| 575 |
+
color: white;
|
| 576 |
+
}
|
| 577 |
+
|
| 578 |
+
.cred-btn.view {
|
| 579 |
+
background-color: #17a2b8;
|
| 580 |
+
color: white;
|
| 581 |
+
}
|
| 582 |
+
|
| 583 |
+
.cred-details {
|
| 584 |
+
margin-top: 10px;
|
| 585 |
+
display: none;
|
| 586 |
+
}
|
| 587 |
+
|
| 588 |
+
.cred-details.show {
|
| 589 |
+
display: block;
|
| 590 |
+
}
|
| 591 |
+
|
| 592 |
+
.cred-content {
|
| 593 |
+
background-color: #f0f8ff;
|
| 594 |
+
border: 1px solid #b0d4ff;
|
| 595 |
+
border-radius: 4px;
|
| 596 |
+
padding: 10px;
|
| 597 |
+
font-family: monospace;
|
| 598 |
+
font-size: 11px;
|
| 599 |
+
white-space: pre-wrap;
|
| 600 |
+
word-break: break-all;
|
| 601 |
+
max-height: 200px;
|
| 602 |
+
overflow-y: auto;
|
| 603 |
+
}
|
| 604 |
+
|
| 605 |
+
/* 额度信息显示样式 */
|
| 606 |
+
.cred-quota-details {
|
| 607 |
+
margin-top: 10px;
|
| 608 |
+
animation: slideDown 0.3s ease-out;
|
| 609 |
+
}
|
| 610 |
+
|
| 611 |
+
@keyframes slideDown {
|
| 612 |
+
from {
|
| 613 |
+
opacity: 0;
|
| 614 |
+
transform: translateY(-10px);
|
| 615 |
+
}
|
| 616 |
+
|
| 617 |
+
to {
|
| 618 |
+
opacity: 1;
|
| 619 |
+
transform: translateY(0);
|
| 620 |
+
}
|
| 621 |
+
}
|
| 622 |
+
|
| 623 |
+
.cred-quota-content {
|
| 624 |
+
background: linear-gradient(to bottom, #ffffff, #f8f9fa);
|
| 625 |
+
border: 2px solid #17a2b8;
|
| 626 |
+
border-radius: 8px;
|
| 627 |
+
padding: 10px;
|
| 628 |
+
box-shadow: 0 2px 8px rgba(23, 162, 184, 0.15);
|
| 629 |
+
}
|
| 630 |
+
|
| 631 |
+
.manage-actions {
|
| 632 |
+
margin-bottom: 20px;
|
| 633 |
+
display: flex;
|
| 634 |
+
gap: 10px;
|
| 635 |
+
flex-wrap: wrap;
|
| 636 |
+
}
|
| 637 |
+
|
| 638 |
+
/* 文件管理新增样式 */
|
| 639 |
+
.stats-container {
|
| 640 |
+
display: flex;
|
| 641 |
+
gap: 15px;
|
| 642 |
+
margin-bottom: 20px;
|
| 643 |
+
flex-wrap: wrap;
|
| 644 |
+
}
|
| 645 |
+
|
| 646 |
+
.stat-item {
|
| 647 |
+
background: linear-gradient(45deg, #f8f9fa, #e9ecef);
|
| 648 |
+
border: 1px solid #dee2e6;
|
| 649 |
+
border-radius: 8px;
|
| 650 |
+
padding: 12px 20px;
|
| 651 |
+
text-align: center;
|
| 652 |
+
min-width: 120px;
|
| 653 |
+
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
| 654 |
+
}
|
| 655 |
+
|
| 656 |
+
.stat-number {
|
| 657 |
+
font-size: 24px;
|
| 658 |
+
font-weight: bold;
|
| 659 |
+
color: #333;
|
| 660 |
+
display: block;
|
| 661 |
+
}
|
| 662 |
+
|
| 663 |
+
.stat-label {
|
| 664 |
+
font-size: 12px;
|
| 665 |
+
color: #666;
|
| 666 |
+
text-transform: uppercase;
|
| 667 |
+
margin-top: 4px;
|
| 668 |
+
}
|
| 669 |
+
|
| 670 |
+
.stat-item.total {
|
| 671 |
+
border-left: 4px solid #007bff;
|
| 672 |
+
}
|
| 673 |
+
|
| 674 |
+
.stat-item.normal {
|
| 675 |
+
border-left: 4px solid #28a745;
|
| 676 |
+
}
|
| 677 |
+
|
| 678 |
+
.stat-item.disabled {
|
| 679 |
+
border-left: 4px solid #6c757d;
|
| 680 |
+
}
|
| 681 |
+
|
| 682 |
+
.filter-container {
|
| 683 |
+
display: flex;
|
| 684 |
+
gap: 10px;
|
| 685 |
+
margin-bottom: 20px;
|
| 686 |
+
align-items: center;
|
| 687 |
+
flex-wrap: wrap;
|
| 688 |
+
}
|
| 689 |
+
|
| 690 |
+
.filter-select {
|
| 691 |
+
padding: 8px 12px;
|
| 692 |
+
border: 2px solid #ddd;
|
| 693 |
+
border-radius: 4px;
|
| 694 |
+
font-size: 14px;
|
| 695 |
+
background-color: white;
|
| 696 |
+
}
|
| 697 |
+
|
| 698 |
+
.filter-select:focus {
|
| 699 |
+
border-color: #4285f4;
|
| 700 |
+
outline: none;
|
| 701 |
+
}
|
| 702 |
+
|
| 703 |
+
.pagination-container {
|
| 704 |
+
display: flex;
|
| 705 |
+
justify-content: center;
|
| 706 |
+
align-items: center;
|
| 707 |
+
gap: 10px;
|
| 708 |
+
margin: 20px 0;
|
| 709 |
+
flex-wrap: wrap;
|
| 710 |
+
}
|
| 711 |
+
|
| 712 |
+
.pagination-info {
|
| 713 |
+
color: #666;
|
| 714 |
+
font-size: 14px;
|
| 715 |
+
}
|
| 716 |
+
|
| 717 |
+
.pagination-btn {
|
| 718 |
+
padding: 8px 12px;
|
| 719 |
+
border: 1px solid #ddd;
|
| 720 |
+
background: white;
|
| 721 |
+
cursor: pointer;
|
| 722 |
+
border-radius: 4px;
|
| 723 |
+
font-size: 14px;
|
| 724 |
+
}
|
| 725 |
+
|
| 726 |
+
.pagination-btn:hover:not(:disabled) {
|
| 727 |
+
background: #f8f9fa;
|
| 728 |
+
border-color: #4285f4;
|
| 729 |
+
}
|
| 730 |
+
|
| 731 |
+
.pagination-btn:disabled {
|
| 732 |
+
background: #f5f5f5;
|
| 733 |
+
color: #ccc;
|
| 734 |
+
cursor: not-allowed;
|
| 735 |
+
}
|
| 736 |
+
|
| 737 |
+
.pagination-btn.active {
|
| 738 |
+
background: #4285f4;
|
| 739 |
+
color: white;
|
| 740 |
+
border-color: #4285f4;
|
| 741 |
+
}
|
| 742 |
+
|
| 743 |
+
.page-size-select {
|
| 744 |
+
padding: 6px 10px;
|
| 745 |
+
border: 1px solid #ddd;
|
| 746 |
+
border-radius: 4px;
|
| 747 |
+
font-size: 14px;
|
| 748 |
+
}
|
| 749 |
+
|
| 750 |
+
.refresh-btn {
|
| 751 |
+
background-color: #17a2b8;
|
| 752 |
+
color: white;
|
| 753 |
+
padding: 8px 16px;
|
| 754 |
+
border: none;
|
| 755 |
+
border-radius: 4px;
|
| 756 |
+
cursor: pointer;
|
| 757 |
+
}
|
| 758 |
+
|
| 759 |
+
.download-all-btn {
|
| 760 |
+
background-color: #28a745;
|
| 761 |
+
color: white;
|
| 762 |
+
padding: 8px 16px;
|
| 763 |
+
border: none;
|
| 764 |
+
border-radius: 4px;
|
| 765 |
+
cursor: pointer;
|
| 766 |
+
}
|
| 767 |
+
|
| 768 |
+
/* 批量操作样式 */
|
| 769 |
+
.batch-controls {
|
| 770 |
+
background-color: #f8f9fa;
|
| 771 |
+
border: 1px solid #e1e4e8;
|
| 772 |
+
border-radius: 8px;
|
| 773 |
+
padding: 15px;
|
| 774 |
+
margin-bottom: 20px;
|
| 775 |
+
}
|
| 776 |
+
|
| 777 |
+
.checkbox-container {
|
| 778 |
+
display: flex;
|
| 779 |
+
align-items: center;
|
| 780 |
+
margin-right: 15px;
|
| 781 |
+
}
|
| 782 |
+
|
| 783 |
+
.batch-actions {
|
| 784 |
+
display: flex;
|
| 785 |
+
gap: 10px;
|
| 786 |
+
flex-wrap: wrap;
|
| 787 |
+
align-items: center;
|
| 788 |
+
margin-top: 10px;
|
| 789 |
+
}
|
| 790 |
+
|
| 791 |
+
.batch-btn {
|
| 792 |
+
padding: 6px 12px;
|
| 793 |
+
border: none;
|
| 794 |
+
border-radius: 4px;
|
| 795 |
+
font-size: 12px;
|
| 796 |
+
cursor: pointer;
|
| 797 |
+
transition: background-color 0.2s;
|
| 798 |
+
}
|
| 799 |
+
|
| 800 |
+
.batch-btn.batch-enable {
|
| 801 |
+
background-color: #28a745;
|
| 802 |
+
color: white;
|
| 803 |
+
}
|
| 804 |
+
|
| 805 |
+
.batch-btn.batch-disable {
|
| 806 |
+
background-color: #6c757d;
|
| 807 |
+
color: white;
|
| 808 |
+
}
|
| 809 |
+
|
| 810 |
+
.batch-btn.batch-delete {
|
| 811 |
+
background-color: #dc3545;
|
| 812 |
+
color: white;
|
| 813 |
+
}
|
| 814 |
+
|
| 815 |
+
.batch-btn.batch-email {
|
| 816 |
+
background-color: #17a2b8;
|
| 817 |
+
color: white;
|
| 818 |
+
}
|
| 819 |
+
|
| 820 |
+
.batch-btn:disabled {
|
| 821 |
+
background-color: #e9ecef;
|
| 822 |
+
color: #6c757d;
|
| 823 |
+
cursor: not-allowed;
|
| 824 |
+
}
|
| 825 |
+
|
| 826 |
+
.cred-btn.email {
|
| 827 |
+
background-color: #17a2b8;
|
| 828 |
+
color: white;
|
| 829 |
+
}
|
| 830 |
+
|
| 831 |
+
.cred-btn.email:hover {
|
| 832 |
+
background-color: #138496;
|
| 833 |
+
}
|
| 834 |
+
|
| 835 |
+
.selected-count {
|
| 836 |
+
font-weight: bold;
|
| 837 |
+
color: #007bff;
|
| 838 |
+
margin-right: 10px;
|
| 839 |
+
}
|
| 840 |
+
|
| 841 |
+
.select-all-checkbox {
|
| 842 |
+
margin-right: 8px;
|
| 843 |
+
transform: scale(1.2);
|
| 844 |
+
}
|
| 845 |
+
|
| 846 |
+
/* 错误码筛选增强 */
|
| 847 |
+
.error-filter-container {
|
| 848 |
+
display: flex;
|
| 849 |
+
gap: 10px;
|
| 850 |
+
align-items: center;
|
| 851 |
+
flex-wrap: wrap;
|
| 852 |
+
}
|
| 853 |
+
|
| 854 |
+
.error-code-badge {
|
| 855 |
+
display: inline-block;
|
| 856 |
+
background-color: #dc3545;
|
| 857 |
+
color: white;
|
| 858 |
+
padding: 2px 6px;
|
| 859 |
+
border-radius: 10px;
|
| 860 |
+
font-size: 11px;
|
| 861 |
+
margin: 1px;
|
| 862 |
+
cursor: pointer;
|
| 863 |
+
transition: background-color 0.2s;
|
| 864 |
+
}
|
| 865 |
+
|
| 866 |
+
.error-code-badge:hover {
|
| 867 |
+
background-color: #c82333;
|
| 868 |
+
}
|
| 869 |
+
|
| 870 |
+
.error-code-badge.selected {
|
| 871 |
+
background-color: #007bff;
|
| 872 |
+
}
|
| 873 |
+
|
| 874 |
+
/* 配置管理样式 */
|
| 875 |
+
.config-group {
|
| 876 |
+
background-color: #f8f9fa;
|
| 877 |
+
border: 1px solid #e1e4e8;
|
| 878 |
+
border-radius: 8px;
|
| 879 |
+
padding: 20px;
|
| 880 |
+
margin: 15px 0;
|
| 881 |
+
}
|
| 882 |
+
|
| 883 |
+
.config-group h4 {
|
| 884 |
+
margin-top: 0;
|
| 885 |
+
margin-bottom: 15px;
|
| 886 |
+
color: #333;
|
| 887 |
+
border-bottom: 1px solid #e1e4e8;
|
| 888 |
+
padding-bottom: 8px;
|
| 889 |
+
}
|
| 890 |
+
|
| 891 |
+
.config-input {
|
| 892 |
+
width: 100%;
|
| 893 |
+
padding: 8px 12px;
|
| 894 |
+
border: 2px solid #ddd;
|
| 895 |
+
border-radius: 4px;
|
| 896 |
+
font-size: 14px;
|
| 897 |
+
box-sizing: border-box;
|
| 898 |
+
margin-bottom: 5px;
|
| 899 |
+
}
|
| 900 |
+
|
| 901 |
+
.config-input:focus {
|
| 902 |
+
border-color: #4285f4;
|
| 903 |
+
outline: none;
|
| 904 |
+
}
|
| 905 |
+
|
| 906 |
+
.config-input:disabled {
|
| 907 |
+
background-color: #f5f5f5;
|
| 908 |
+
color: #666;
|
| 909 |
+
cursor: not-allowed;
|
| 910 |
+
}
|
| 911 |
+
|
| 912 |
+
.config-checkbox {
|
| 913 |
+
margin-right: 8px;
|
| 914 |
+
transform: scale(1.2);
|
| 915 |
+
}
|
| 916 |
+
|
| 917 |
+
.config-note {
|
| 918 |
+
display: block;
|
| 919 |
+
color: #666;
|
| 920 |
+
font-size: 12px;
|
| 921 |
+
margin-bottom: 10px;
|
| 922 |
+
font-style: italic;
|
| 923 |
+
}
|
| 924 |
+
|
| 925 |
+
.config-info {
|
| 926 |
+
background-color: #e3f2fd;
|
| 927 |
+
border: 1px solid #1976d2;
|
| 928 |
+
border-radius: 4px;
|
| 929 |
+
padding: 12px;
|
| 930 |
+
margin-top: 8px;
|
| 931 |
+
font-size: 13px;
|
| 932 |
+
color: #1565c0;
|
| 933 |
+
}
|
| 934 |
+
|
| 935 |
+
.config-info ul {
|
| 936 |
+
margin: 8px 0 4px 0;
|
| 937 |
+
color: #424242;
|
| 938 |
+
}
|
| 939 |
+
|
| 940 |
+
.config-info li {
|
| 941 |
+
margin: 3px 0;
|
| 942 |
+
}
|
| 943 |
+
|
| 944 |
+
.env-locked {
|
| 945 |
+
position: relative;
|
| 946 |
+
}
|
| 947 |
+
|
| 948 |
+
.env-locked::after {
|
| 949 |
+
content: "🔒 环境变量锚定";
|
| 950 |
+
position: absolute;
|
| 951 |
+
right: 10px;
|
| 952 |
+
top: 50%;
|
| 953 |
+
transform: translateY(-50%);
|
| 954 |
+
background-color: #ffc107;
|
| 955 |
+
color: #212529;
|
| 956 |
+
padding: 2px 6px;
|
| 957 |
+
border-radius: 3px;
|
| 958 |
+
font-size: 11px;
|
| 959 |
+
pointer-events: none;
|
| 960 |
+
}
|
| 961 |
+
|
| 962 |
+
/* 使用统计样式 */
|
| 963 |
+
.usage-card {
|
| 964 |
+
background-color: #f8f9fa;
|
| 965 |
+
border: 1px solid #e1e4e8;
|
| 966 |
+
border-radius: 8px;
|
| 967 |
+
padding: 15px;
|
| 968 |
+
margin: 10px 0;
|
| 969 |
+
position: relative;
|
| 970 |
+
}
|
| 971 |
+
|
| 972 |
+
.usage-header {
|
| 973 |
+
display: flex;
|
| 974 |
+
justify-content: space-between;
|
| 975 |
+
align-items: center;
|
| 976 |
+
margin-bottom: 15px;
|
| 977 |
+
}
|
| 978 |
+
|
| 979 |
+
.usage-filename {
|
| 980 |
+
font-family: monospace;
|
| 981 |
+
font-weight: bold;
|
| 982 |
+
color: #333;
|
| 983 |
+
font-size: 14px;
|
| 984 |
+
}
|
| 985 |
+
|
| 986 |
+
.usage-progress {
|
| 987 |
+
margin: 10px 0;
|
| 988 |
+
}
|
| 989 |
+
|
| 990 |
+
.usage-progress-label {
|
| 991 |
+
display: flex;
|
| 992 |
+
justify-content: space-between;
|
| 993 |
+
align-items: center;
|
| 994 |
+
margin-bottom: 5px;
|
| 995 |
+
font-size: 12px;
|
| 996 |
+
}
|
| 997 |
+
|
| 998 |
+
.usage-progress-bar {
|
| 999 |
+
width: 100%;
|
| 1000 |
+
height: 20px;
|
| 1001 |
+
background-color: #e9ecef;
|
| 1002 |
+
border-radius: 10px;
|
| 1003 |
+
overflow: hidden;
|
| 1004 |
+
position: relative;
|
| 1005 |
+
}
|
| 1006 |
+
|
| 1007 |
+
.usage-progress-fill {
|
| 1008 |
+
height: 100%;
|
| 1009 |
+
transition: width 0.3s ease;
|
| 1010 |
+
}
|
| 1011 |
+
|
| 1012 |
+
.usage-progress-fill.gemini {
|
| 1013 |
+
background-color: #ff6b35;
|
| 1014 |
+
}
|
| 1015 |
+
|
| 1016 |
+
.usage-progress-fill.total {
|
| 1017 |
+
background-color: #007bff;
|
| 1018 |
+
}
|
| 1019 |
+
|
| 1020 |
+
.usage-progress-fill.warning {
|
| 1021 |
+
background-color: #ffc107;
|
| 1022 |
+
}
|
| 1023 |
+
|
| 1024 |
+
.usage-progress-fill.danger {
|
| 1025 |
+
background-color: #dc3545;
|
| 1026 |
+
}
|
| 1027 |
+
|
| 1028 |
+
.usage-actions {
|
| 1029 |
+
display: flex;
|
| 1030 |
+
gap: 5px;
|
| 1031 |
+
margin-top: 10px;
|
| 1032 |
+
}
|
| 1033 |
+
|
| 1034 |
+
.usage-btn {
|
| 1035 |
+
padding: 4px 8px;
|
| 1036 |
+
border: none;
|
| 1037 |
+
border-radius: 4px;
|
| 1038 |
+
font-size: 11px;
|
| 1039 |
+
cursor: pointer;
|
| 1040 |
+
transition: background-color 0.2s;
|
| 1041 |
+
}
|
| 1042 |
+
|
| 1043 |
+
.usage-btn.reset {
|
| 1044 |
+
background-color: #6c757d;
|
| 1045 |
+
color: white;
|
| 1046 |
+
}
|
| 1047 |
+
|
| 1048 |
+
.usage-btn.limits {
|
| 1049 |
+
background-color: #17a2b8;
|
| 1050 |
+
color: white;
|
| 1051 |
+
}
|
| 1052 |
+
|
| 1053 |
+
.usage-info {
|
| 1054 |
+
display: grid;
|
| 1055 |
+
grid-template-columns: 1fr 1fr;
|
| 1056 |
+
gap: 10px;
|
| 1057 |
+
margin-top: 10px;
|
| 1058 |
+
font-size: 12px;
|
| 1059 |
+
}
|
| 1060 |
+
|
| 1061 |
+
.usage-info-item {
|
| 1062 |
+
background-color: #ffffff;
|
| 1063 |
+
padding: 8px;
|
| 1064 |
+
border-radius: 4px;
|
| 1065 |
+
border: 1px solid #dee2e6;
|
| 1066 |
+
}
|
| 1067 |
+
|
| 1068 |
+
.usage-info-label {
|
| 1069 |
+
font-weight: bold;
|
| 1070 |
+
color: #666;
|
| 1071 |
+
display: block;
|
| 1072 |
+
margin-bottom: 2px;
|
| 1073 |
+
}
|
| 1074 |
+
|
| 1075 |
+
.usage-info-value {
|
| 1076 |
+
color: #333;
|
| 1077 |
+
}
|
| 1078 |
+
|
| 1079 |
+
.reset-time {
|
| 1080 |
+
font-size: 11px;
|
| 1081 |
+
color: #666;
|
| 1082 |
+
font-style: italic;
|
| 1083 |
+
margin-top: 5px;
|
| 1084 |
+
}
|
| 1085 |
+
|
| 1086 |
+
/* 限制设置弹窗样式 */
|
| 1087 |
+
.modal {
|
| 1088 |
+
display: none;
|
| 1089 |
+
position: fixed;
|
| 1090 |
+
z-index: 1000;
|
| 1091 |
+
left: 0;
|
| 1092 |
+
top: 0;
|
| 1093 |
+
width: 100%;
|
| 1094 |
+
height: 100%;
|
| 1095 |
+
background-color: rgba(0, 0, 0, 0.5);
|
| 1096 |
+
}
|
| 1097 |
+
|
| 1098 |
+
.modal-content {
|
| 1099 |
+
background-color: #fefefe;
|
| 1100 |
+
margin: 15% auto;
|
| 1101 |
+
padding: 20px;
|
| 1102 |
+
border-radius: 8px;
|
| 1103 |
+
width: 400px;
|
| 1104 |
+
max-width: 90%;
|
| 1105 |
+
}
|
| 1106 |
+
|
| 1107 |
+
.modal-header {
|
| 1108 |
+
display: flex;
|
| 1109 |
+
justify-content: space-between;
|
| 1110 |
+
align-items: center;
|
| 1111 |
+
margin-bottom: 15px;
|
| 1112 |
+
padding-bottom: 10px;
|
| 1113 |
+
border-bottom: 1px solid #dee2e6;
|
| 1114 |
+
}
|
| 1115 |
+
|
| 1116 |
+
.modal-title {
|
| 1117 |
+
margin: 0;
|
| 1118 |
+
font-size: 16px;
|
| 1119 |
+
color: #333;
|
| 1120 |
+
}
|
| 1121 |
+
|
| 1122 |
+
.modal-close {
|
| 1123 |
+
background: none;
|
| 1124 |
+
border: none;
|
| 1125 |
+
font-size: 20px;
|
| 1126 |
+
cursor: pointer;
|
| 1127 |
+
color: #999;
|
| 1128 |
+
}
|
| 1129 |
+
|
| 1130 |
+
.modal-close:hover {
|
| 1131 |
+
color: #333;
|
| 1132 |
+
}
|
| 1133 |
+
|
| 1134 |
+
.modal-body {
|
| 1135 |
+
margin-bottom: 15px;
|
| 1136 |
+
}
|
| 1137 |
+
|
| 1138 |
+
.modal-footer {
|
| 1139 |
+
display: flex;
|
| 1140 |
+
gap: 10px;
|
| 1141 |
+
justify-content: flex-end;
|
| 1142 |
+
}
|
| 1143 |
+
</style>
|
| 1144 |
+
</head>
|
| 1145 |
+
|
| 1146 |
+
<body>
|
| 1147 |
+
<div class="container">
|
| 1148 |
+
|
| 1149 |
+
<!-- 登录界面 -->
|
| 1150 |
+
<div id="loginSection" class="login-form">
|
| 1151 |
+
<h1>GCLI2API 管理面板</h1>
|
| 1152 |
+
<p>请输入访问密码:</p>
|
| 1153 |
+
<input type="password" id="loginPassword" placeholder="输入密码" onkeypress="handlePasswordEnter(event)" />
|
| 1154 |
+
<br>
|
| 1155 |
+
<button class="btn" onclick="login()">登录</button>
|
| 1156 |
+
</div>
|
| 1157 |
+
|
| 1158 |
+
<!-- 主界面 -->
|
| 1159 |
+
<div id="mainSection" class="hidden">
|
| 1160 |
+
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; flex-wrap: wrap; gap: 10px;">
|
| 1161 |
+
<div style="display: flex; align-items: center; gap: 15px; flex-wrap: wrap;">
|
| 1162 |
+
<h1 style="margin: 0;">GCLI2API 管理面板</h1>
|
| 1163 |
+
<span id="versionInfo" style="font-size: 12px; color: #666;">
|
| 1164 |
+
<span id="versionText">加载中...</span>
|
| 1165 |
+
</span>
|
| 1166 |
+
<button onclick="checkForUpdates()" id="checkUpdateBtn"
|
| 1167 |
+
style="padding: 4px 12px; background-color: #17a2b8; color: white; border: none; border-radius: 4px; font-size: 12px; cursor: pointer; white-space: nowrap;">
|
| 1168 |
+
检查更新
|
| 1169 |
+
</button>
|
| 1170 |
+
</div>
|
| 1171 |
+
<button onclick="logout()"
|
| 1172 |
+
style="padding: 8px 20px; background-color: #dc3545; color: white; border: none; border-radius: 5px; font-size: 14px; cursor: pointer;">
|
| 1173 |
+
退出登录
|
| 1174 |
+
</button>
|
| 1175 |
+
</div>
|
| 1176 |
+
|
| 1177 |
+
<!-- 标签页 -->
|
| 1178 |
+
<div class="tabs">
|
| 1179 |
+
<div class="tab-slider"></div>
|
| 1180 |
+
<button class="tab active" onclick="switchTab('oauth')">OAuth认证</button>
|
| 1181 |
+
<button class="tab" onclick="switchTab('antigravity')">Antigravity认证</button>
|
| 1182 |
+
<button class="tab" onclick="switchTab('upload')">批量上传</button>
|
| 1183 |
+
<button class="tab" onclick="switchTab('manage')">GCLI凭证管理</button>
|
| 1184 |
+
<button class="tab" onclick="switchTab('antigravity-manage')">Antigravity凭证管理</button>
|
| 1185 |
+
<button class="tab" onclick="switchTab('config')">配置管理</button>
|
| 1186 |
+
<button class="tab" onclick="switchTab('logs')">实时日志</button>
|
| 1187 |
+
<button class="tab" onclick="switchTab('about')">项目信息</button>
|
| 1188 |
+
</div>
|
| 1189 |
+
|
| 1190 |
+
<!-- OAuth认证标签页 -->
|
| 1191 |
+
<div id="oauthTab" class="tab-content active">
|
| 1192 |
+
<!-- API 自动启用说明 -->
|
| 1193 |
+
<div class="status success" style="margin-bottom: 20px;">
|
| 1194 |
+
<strong>✨ 自动化优化:</strong> 系统现在会在认证成功后自动为您的项目启用必需的API服务
|
| 1195 |
+
<ul style="margin: 10px 0; padding-left: 20px;">
|
| 1196 |
+
<li><strong>Gemini Cloud Assist API</strong></li>
|
| 1197 |
+
<li><strong>Gemini for Google Cloud API</strong></li>
|
| 1198 |
+
</ul>
|
| 1199 |
+
<p style="margin: 10px 0; color: #155724;"><strong>说明:</strong>无需手动启用API,系统会自动处理这些配置步骤,让认证流程更加顺畅。
|
| 1200 |
+
</p>
|
| 1201 |
+
</div>
|
| 1202 |
+
|
| 1203 |
+
<!-- 折叠式 Project ID 输入框 -->
|
| 1204 |
+
<div class="form-group">
|
| 1205 |
+
<div style="cursor: pointer; user-select: none; padding: 12px; border: 2px solid #ddd; border-radius: 5px; background: #f8f9fa; display: flex; justify-content: space-between; align-items: center;"
|
| 1206 |
+
onclick="toggleProjectIdSection()">
|
| 1207 |
+
<span style="font-weight: bold; color: #555;">📁 高级选项:Google Cloud Project ID
|
| 1208 |
+
(不用管,直接点击获取链接即可)</span>
|
| 1209 |
+
<span id="projectIdToggleIcon"
|
| 1210 |
+
style="font-size: 14px; color: #666; transition: transform 0.3s ease;">▶</span>
|
| 1211 |
+
</div>
|
| 1212 |
+
<div id="projectIdSection"
|
| 1213 |
+
style="display: none; margin-top: 15px; padding: 15px; border: 2px solid #ddd; border-top: none; border-radius: 0 0 5px 5px; background: #ffffff;">
|
| 1214 |
+
<label for="projectId"
|
| 1215 |
+
style="display: block; margin-bottom: 5px; font-weight: bold; color: #555;">Google Cloud
|
| 1216 |
+
Project ID (可选):</label>
|
| 1217 |
+
<input type="text" id="projectId"
|
| 1218 |
+
style="width: 100%; padding: 10px; border: 2px solid #ddd; border-radius: 5px; font-size: 16px; box-sizing: border-box;"
|
| 1219 |
+
placeholder="留空将尝试自动检测,或手动输入项目ID" />
|
| 1220 |
+
<small style="color: #666; font-size: 12px; margin-top: 5px; display: block;">
|
| 1221 |
+
💡 提示:如果你不懂这是什么,可以留空此字段让系统自动检测项目ID
|
| 1222 |
+
</small>
|
| 1223 |
+
</div>
|
| 1224 |
+
</div>
|
| 1225 |
+
|
| 1226 |
+
<button class="btn" id="getAuthBtn" onclick="startAuth()">获取认证链接</button>
|
| 1227 |
+
|
| 1228 |
+
<div id="authUrlSection" class="hidden">
|
| 1229 |
+
<h3>认证链接:</h3>
|
| 1230 |
+
<div class="auth-url">
|
| 1231 |
+
<a id="authUrl" href="#" target="_blank">点击此链接进行认证</a>
|
| 1232 |
+
</div>
|
| 1233 |
+
<div class="status info">
|
| 1234 |
+
<strong>重要说明:</strong>
|
| 1235 |
+
<ol style="margin: 10px 0; padding-left: 20px;">
|
| 1236 |
+
<li>点击上方认证链接,会在新窗口中打开Google OAuth页面</li>
|
| 1237 |
+
<li>完成Google账号登录和授权</li>
|
| 1238 |
+
<li>授权成功后会跳转到localhost:11451显示成功页面</li>
|
| 1239 |
+
<li>关闭OAuth窗口,返回本页面</li>
|
| 1240 |
+
<li>点击下方"获取认证文件"按钮完成流程</li>
|
| 1241 |
+
</ol>
|
| 1242 |
+
</div>
|
| 1243 |
+
|
| 1244 |
+
<!-- 快捷回调URL输入选项 -->
|
| 1245 |
+
<div class="form-group"
|
| 1246 |
+
style="margin: 20px 0; padding: 15px; border: 2px solid #e8f4fd; border-radius: 8px; background: #f8fcff;">
|
| 1247 |
+
<div style="cursor: pointer; user-select: none; display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;"
|
| 1248 |
+
onclick="toggleCallbackUrlSection()">
|
| 1249 |
+
<span style="font-weight: bold; color: #0066cc;">🚀 无法回源?试试快捷方式</span>
|
| 1250 |
+
<span id="callbackUrlToggleIcon"
|
| 1251 |
+
style="font-size: 14px; color: #666; transition: transform 0.3s ease;">▼</span>
|
| 1252 |
+
</div>
|
| 1253 |
+
<div id="callbackUrlSection" style="display: none;">
|
| 1254 |
+
<div
|
| 1255 |
+
style="background: #fff3cd; border: 1px solid #ffeaa7; border-radius: 6px; padding: 12px; margin-bottom: 12px;">
|
| 1256 |
+
<div style="color: #856404; font-size: 14px; font-weight: bold; margin-bottom: 6px;">📚
|
| 1257 |
+
适用场景:</div>
|
| 1258 |
+
<ul
|
| 1259 |
+
style="color: #856404; font-size: 13px; margin: 0; padding-left: 18px; line-height: 1.5;">
|
| 1260 |
+
<li>云服务器、VPS等非本地环境</li>
|
| 1261 |
+
<li>防火墙阻止了11451端口访问</li>
|
| 1262 |
+
<li>网络环境无法正常回源到localhost</li>
|
| 1263 |
+
<li>Docker容器内运行,端口映射问题</li>
|
| 1264 |
+
</ul>
|
| 1265 |
+
</div>
|
| 1266 |
+
<div style="color: #666; font-size: 13px; margin-bottom: 12px; line-height: 1.6;">
|
| 1267 |
+
<strong style="color: #0066cc;">🔍 什么是回调URL?</strong><br>
|
| 1268 |
+
完成Google OAuth授权后,浏览器地址栏显示的完整URL,通常看起来像这样:<br>
|
| 1269 |
+
<code
|
| 1270 |
+
style="background: #f1f3f4; padding: 2px 6px; border-radius: 3px; font-size: 12px; word-break: break-all;">
|
| 1271 |
+
http://localhost:11451/?state=abc123...&code=4/0AVMBsJ...&scope=email%20profile...
|
| 1272 |
+
</code>
|
| 1273 |
+
</div>
|
| 1274 |
+
<div
|
| 1275 |
+
style="background: #e7f3ff; border: 1px solid #b3d9ff; border-radius: 6px; padding: 10px; margin-bottom: 12px;">
|
| 1276 |
+
<div style="color: #0066cc; font-size: 13px; font-weight: bold; margin-bottom: 4px;">📋
|
| 1277 |
+
使用步骤:</div>
|
| 1278 |
+
<ol
|
| 1279 |
+
style="color: #0066cc; font-size: 12px; margin: 0; padding-left: 18px; line-height: 1.4;">
|
| 1280 |
+
<li>点击上方认证链接,完成Google授权</li>
|
| 1281 |
+
<li>授权成功后,复制浏览器地址栏的<strong>完整URL</strong></li>
|
| 1282 |
+
<li>粘贴到下方输入框,点击获取凭证即可</li>
|
| 1283 |
+
</ol>
|
| 1284 |
+
</div>
|
| 1285 |
+
<div class="input-group">
|
| 1286 |
+
<input type="url" id="callbackUrlInput"
|
| 1287 |
+
placeholder="粘贴完整的回调URL,例如:http://localhost:11451/?state=xxx&code=xxx&scope=xxx..."
|
| 1288 |
+
style="width: 100%; padding: 10px; border: 2px solid #ddd; border-radius: 4px; font-size: 13px;">
|
| 1289 |
+
</div>
|
| 1290 |
+
<button class="btn" style="margin-top: 10px; background: #28a745; border-color: #28a745;"
|
| 1291 |
+
onclick="processCallbackUrl()">
|
| 1292 |
+
从回调URL获取凭证
|
| 1293 |
+
</button>
|
| 1294 |
+
</div>
|
| 1295 |
+
</div>
|
| 1296 |
+
|
| 1297 |
+
<button class="btn" id="getCredsBtn" onclick="getCredentials()">获取认证文件</button>
|
| 1298 |
+
</div>
|
| 1299 |
+
|
| 1300 |
+
<div id="credentialsSection" class="hidden">
|
| 1301 |
+
<h3>认证文件内容:</h3>
|
| 1302 |
+
<div class="credentials" id="credentialsContent"></div>
|
| 1303 |
+
</div>
|
| 1304 |
+
</div>
|
| 1305 |
+
|
| 1306 |
+
<!-- Antigravity 认证标签页 -->
|
| 1307 |
+
<div id="antigravityTab" class="tab-content">
|
| 1308 |
+
<div class="status info" style="margin-bottom: 20px;">
|
| 1309 |
+
<strong>🚀 Antigravity 认证模式</strong>
|
| 1310 |
+
<p style="margin: 10px 0;">
|
| 1311 |
+
获取谷歌Antigravity 凭证
|
| 1312 |
+
</p>
|
| 1313 |
+
</div>
|
| 1314 |
+
|
| 1315 |
+
<button class="btn" id="getAntigravityAuthBtn">获取 Antigravity 认证链接</button>
|
| 1316 |
+
|
| 1317 |
+
<div id="antigravityAuthUrlSection" class="hidden">
|
| 1318 |
+
<h3>Antigravity 认证链接:</h3>
|
| 1319 |
+
<div class="auth-url">
|
| 1320 |
+
<a id="antigravityAuthUrl" href="#" target="_blank">点击此链接进行认证</a>
|
| 1321 |
+
</div>
|
| 1322 |
+
<div class="status info">
|
| 1323 |
+
<strong>使用说明:</strong>
|
| 1324 |
+
<ol style="margin: 10px 0; padding-left: 20px;">
|
| 1325 |
+
<li>点击上方认证链接,在新窗口中完成 Google 授权</li>
|
| 1326 |
+
<li>授权成功后会跳转到 localhost 显示成功页面</li>
|
| 1327 |
+
<li>关闭 OAuth 窗口,返回本页面</li>
|
| 1328 |
+
<li>点击下方"获取凭证"按钮完成流程</li>
|
| 1329 |
+
</ol>
|
| 1330 |
+
</div>
|
| 1331 |
+
|
| 1332 |
+
<!-- 快捷回调URL输入选项 -->
|
| 1333 |
+
<div class="form-group"
|
| 1334 |
+
style="margin: 20px 0; padding: 15px; border: 2px solid #e8f4fd; border-radius: 8px; background: #f8fcff;">
|
| 1335 |
+
<div style="cursor: pointer; user-select: none; display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;"
|
| 1336 |
+
onclick="toggleAntigravityCallbackUrlSection()">
|
| 1337 |
+
<span style="font-weight: bold; color: #0066cc;">🚀 无法回源?试试快捷方式</span>
|
| 1338 |
+
<span id="antigravityCallbackUrlToggleIcon"
|
| 1339 |
+
style="font-size: 14px; color: #666; transition: transform 0.3s ease;">▼</span>
|
| 1340 |
+
</div>
|
| 1341 |
+
<div id="antigravityCallbackUrlSection" style="display: none;">
|
| 1342 |
+
<div
|
| 1343 |
+
style="background: #fff3cd; border: 1px solid #ffeaa7; border-radius: 6px; padding: 12px; margin-bottom: 12px;">
|
| 1344 |
+
<div style="color: #856404; font-size: 14px; font-weight: bold; margin-bottom: 6px;">📚
|
| 1345 |
+
适用场景:</div>
|
| 1346 |
+
<ul
|
| 1347 |
+
style="color: #856404; font-size: 13px; margin: 0; padding-left: 18px; line-height: 1.5;">
|
| 1348 |
+
<li>云服务器、VPS等非本地环境</li>
|
| 1349 |
+
<li>防火墙阻止了11451端口访问</li>
|
| 1350 |
+
<li>网络环境无法正常回源到localhost</li>
|
| 1351 |
+
<li>Docker容器内运行,端口映射问题</li>
|
| 1352 |
+
</ul>
|
| 1353 |
+
</div>
|
| 1354 |
+
<div style="color: #666; font-size: 13px; margin-bottom: 12px; line-height: 1.6;">
|
| 1355 |
+
<strong style="color: #0066cc;">🔍 什么是回调URL?</strong><br>
|
| 1356 |
+
完成Google OAuth授权后,浏览器地址栏显示的完整URL,通常看起来像这样:<br>
|
| 1357 |
+
<code
|
| 1358 |
+
style="background: #f1f3f4; padding: 2px 6px; border-radius: 3px; font-size: 12px; word-break: break-all;">
|
| 1359 |
+
http://localhost:11451/?state=abc123...&code=4/0AVMBsJ...&scope=email%20profile...
|
| 1360 |
+
</code>
|
| 1361 |
+
</div>
|
| 1362 |
+
<div
|
| 1363 |
+
style="background: #e7f3ff; border: 1px solid #b3d9ff; border-radius: 6px; padding: 10px; margin-bottom: 12px;">
|
| 1364 |
+
<div style="color: #0066cc; font-size: 13px; font-weight: bold; margin-bottom: 4px;">📋
|
| 1365 |
+
使用步骤:</div>
|
| 1366 |
+
<ol
|
| 1367 |
+
style="color: #0066cc; font-size: 12px; margin: 0; padding-left: 18px; line-height: 1.4;">
|
| 1368 |
+
<li>点击上方认证链接,完成Google授权</li>
|
| 1369 |
+
<li>授权成功后,复制浏览器地址栏的<strong>完整URL</strong></li>
|
| 1370 |
+
<li>粘贴到下方输入框,点击获取凭证即可</li>
|
| 1371 |
+
</ol>
|
| 1372 |
+
</div>
|
| 1373 |
+
<div class="input-group">
|
| 1374 |
+
<input type="url" id="antigravityCallbackUrlInput"
|
| 1375 |
+
placeholder="粘贴完整的回调URL,例如:http://localhost:11451/?state=xxx&code=xxx&scope=xxx..."
|
| 1376 |
+
style="width: 100%; padding: 10px; border: 2px solid #ddd; border-radius: 4px; font-size: 13px;">
|
| 1377 |
+
</div>
|
| 1378 |
+
<button class="btn" style="margin-top: 10px; background: #28a745; border-color: #28a745;"
|
| 1379 |
+
onclick="processAntigravityCallbackUrl()">
|
| 1380 |
+
从回调URL获取凭证
|
| 1381 |
+
</button>
|
| 1382 |
+
</div>
|
| 1383 |
+
</div>
|
| 1384 |
+
|
| 1385 |
+
<button class="btn" id="getAntigravityCredsBtn" onclick="getAntigravityCredentials()">获取 Antigravity
|
| 1386 |
+
凭证</button>
|
| 1387 |
+
|
| 1388 |
+
<div id="antigravityCredsSection" class="hidden">
|
| 1389 |
+
<h3>Antigravity 凭证内容:</h3>
|
| 1390 |
+
<div class="credentials">
|
| 1391 |
+
<pre id="antigravityCredsContent"></pre>
|
| 1392 |
+
</div>
|
| 1393 |
+
<button class="btn" onclick="downloadAntigravityCredentials()">下载凭证文件</button>
|
| 1394 |
+
</div>
|
| 1395 |
+
</div>
|
| 1396 |
+
</div>
|
| 1397 |
+
|
| 1398 |
+
<!-- 批量上传标签页 -->
|
| 1399 |
+
<div id="uploadTab" class="tab-content">
|
| 1400 |
+
<h3>批量上传认证文件</h3>
|
| 1401 |
+
<p>支持批量上传 GCLI 和 Antigravity 认证文件</p>
|
| 1402 |
+
|
| 1403 |
+
<!-- GCLI凭证上传区域 -->
|
| 1404 |
+
<div
|
| 1405 |
+
style="margin-bottom: 30px; padding: 20px; border: 2px solid #e1e4e8; border-radius: 8px; background: #f8f9fa;">
|
| 1406 |
+
<h4 style="margin-top: 0; color: #007bff; border-bottom: 2px solid #007bff; padding-bottom: 10px;">
|
| 1407 |
+
📤 GCLI 凭证批量上传</h4>
|
| 1408 |
+
|
| 1409 |
+
<div class="upload-area" id="uploadArea" onclick="document.getElementById('fileInput').click()">
|
| 1410 |
+
<p>点击选择文件或拖拽文件到此区域</p>
|
| 1411 |
+
<p style="color: #666; font-size: 14px;">支持 .json 和 .zip 格式文件</p>
|
| 1412 |
+
<p style="color: #888; font-size: 12px;">ZIP文件会自动解压提取其中的JSON凭证</p>
|
| 1413 |
+
</div>
|
| 1414 |
+
|
| 1415 |
+
<input type="file" id="fileInput" multiple accept=".json,.zip" style="display: none;"
|
| 1416 |
+
onchange="handleFileSelect(event)" />
|
| 1417 |
+
|
| 1418 |
+
<div id="fileListSection" class="hidden">
|
| 1419 |
+
<h4>选择的文件:</h4>
|
| 1420 |
+
<div class="file-list" id="fileList"></div>
|
| 1421 |
+
<button class="btn" onclick="uploadFiles()">上传文件</button>
|
| 1422 |
+
<button class="btn" style="background-color: #6c757d;" onclick="clearFiles()">清空列表</button>
|
| 1423 |
+
</div>
|
| 1424 |
+
|
| 1425 |
+
<div id="uploadProgressSection" class="hidden">
|
| 1426 |
+
<div class="upload-progress">
|
| 1427 |
+
<h4>上传进度:</h4>
|
| 1428 |
+
<div class="progress-bar">
|
| 1429 |
+
<div class="progress-fill" id="progressFill" style="width: 0%"></div>
|
| 1430 |
+
</div>
|
| 1431 |
+
<p id="progressText">0%</p>
|
| 1432 |
+
</div>
|
| 1433 |
+
</div>
|
| 1434 |
+
</div>
|
| 1435 |
+
|
| 1436 |
+
<!-- Antigravity凭证上传区域 -->
|
| 1437 |
+
<div style="padding: 20px; border: 2px solid #e1e4e8; border-radius: 8px; background: #f8f9fa;">
|
| 1438 |
+
<h4 style="margin-top: 0; color: #28a745; border-bottom: 2px solid #28a745; padding-bottom: 10px;">
|
| 1439 |
+
📤 Antigravity 凭证批量上传</h4>
|
| 1440 |
+
|
| 1441 |
+
<div class="upload-area" style="border-color: #28a745;"
|
| 1442 |
+
onclick="document.getElementById('antigravityFileInput').click()"
|
| 1443 |
+
ondragover="event.preventDefault(); this.style.borderColor='#28a745'; this.style.backgroundColor='#e7f9e7';"
|
| 1444 |
+
ondragleave="this.style.borderColor='#ddd'; this.style.backgroundColor='#fafafa';"
|
| 1445 |
+
ondrop="handleAntigravityFileDrop(event)">
|
| 1446 |
+
<p>点击选择文件或拖拽文件到此区域</p>
|
| 1447 |
+
<p style="color: #666; font-size: 14px;">支持 .json 和 .zip 格式文件</p>
|
| 1448 |
+
<p style="color: #888; font-size: 12px;">ZIP文件会自动解压提取其中的JSON凭证</p>
|
| 1449 |
+
</div>
|
| 1450 |
+
|
| 1451 |
+
<input type="file" id="antigravityFileInput" multiple accept=".json,.zip" style="display: none;"
|
| 1452 |
+
onchange="handleAntigravityFileSelect(event)" />
|
| 1453 |
+
|
| 1454 |
+
<div id="antigravityFileListSection" class="hidden">
|
| 1455 |
+
<h4>选择的文件:</h4>
|
| 1456 |
+
<div class="file-list" id="antigravityFileList"></div>
|
| 1457 |
+
<button class="btn" style="background-color: #28a745;"
|
| 1458 |
+
onclick="uploadAntigravityFiles()">上传文件</button>
|
| 1459 |
+
<button class="btn" style="background-color: #6c757d;"
|
| 1460 |
+
onclick="clearAntigravityFiles()">清空列表</button>
|
| 1461 |
+
</div>
|
| 1462 |
+
|
| 1463 |
+
<div id="antigravityUploadProgressSection" class="hidden">
|
| 1464 |
+
<div class="upload-progress">
|
| 1465 |
+
<h4>上传进度:</h4>
|
| 1466 |
+
<div class="progress-bar">
|
| 1467 |
+
<div class="progress-fill" id="antigravityProgressFill" style="width: 0%"></div>
|
| 1468 |
+
</div>
|
| 1469 |
+
<p id="antigravityProgressText">0%</p>
|
| 1470 |
+
</div>
|
| 1471 |
+
</div>
|
| 1472 |
+
</div>
|
| 1473 |
+
</div>
|
| 1474 |
+
|
| 1475 |
+
<!-- GCLI凭证管理标签页 -->
|
| 1476 |
+
<div id="manageTab" class="tab-content">
|
| 1477 |
+
<h3>GCLI凭证文件管理</h3>
|
| 1478 |
+
<p>管理所有GCLI认证文件,查看状态和执行操作</p>
|
| 1479 |
+
|
| 1480 |
+
<!-- 检验功能说明 -->
|
| 1481 |
+
<div class="status info" style="margin-bottom: 20px;">
|
| 1482 |
+
<strong>💡 检验功能说明:</strong>
|
| 1483 |
+
<p style="margin: 10px 0;">
|
| 1484 |
+
点击每个凭证的"检验"按钮可以重新获取Project ID。<br>
|
| 1485 |
+
<strong style="color: #0c5460;">✅ 检验成功可以恢复403错误</strong>,让凭证重新正常工作。<br>
|
| 1486 |
+
建议在遇到403错误时使用此功能。
|
| 1487 |
+
</p>
|
| 1488 |
+
</div>
|
| 1489 |
+
|
| 1490 |
+
<!-- 状态统计 -->
|
| 1491 |
+
<div class="stats-container" id="statsContainer">
|
| 1492 |
+
<div class="stat-item total">
|
| 1493 |
+
<span class="stat-number" id="statTotal">0</span>
|
| 1494 |
+
<span class="stat-label">总计</span>
|
| 1495 |
+
</div>
|
| 1496 |
+
<div class="stat-item normal">
|
| 1497 |
+
<span class="stat-number" id="statNormal">0</span>
|
| 1498 |
+
<span class="stat-label">正常</span>
|
| 1499 |
+
</div>
|
| 1500 |
+
<div class="stat-item disabled">
|
| 1501 |
+
<span class="stat-number" id="statDisabled">0</span>
|
| 1502 |
+
<span class="stat-label">禁用</span>
|
| 1503 |
+
</div>
|
| 1504 |
+
</div>
|
| 1505 |
+
|
| 1506 |
+
<div class="manage-actions">
|
| 1507 |
+
<button class="refresh-btn" onclick="refreshCredsStatus()">刷新状态</button>
|
| 1508 |
+
<button class="download-all-btn" onclick="downloadAllCreds()">打包下载所有文件</button>
|
| 1509 |
+
</div>
|
| 1510 |
+
|
| 1511 |
+
<!-- 批量操作控件 -->
|
| 1512 |
+
<div class="batch-controls">
|
| 1513 |
+
<h4 style="margin-top: 0; margin-bottom: 10px;">批量操作</h4>
|
| 1514 |
+
<div class="batch-actions">
|
| 1515 |
+
<div class="checkbox-container">
|
| 1516 |
+
<input type="checkbox" id="selectAllCheckbox" class="select-all-checkbox"
|
| 1517 |
+
onchange="toggleSelectAll()">
|
| 1518 |
+
<label for="selectAllCheckbox">全选</label>
|
| 1519 |
+
</div>
|
| 1520 |
+
<span class="selected-count" id="selectedCount">已选择 0 项</span>
|
| 1521 |
+
<button class="batch-btn batch-enable" id="batchEnableBtn" onclick="batchAction('enable')"
|
| 1522 |
+
disabled>批量启用</button>
|
| 1523 |
+
<button class="batch-btn batch-disable" id="batchDisableBtn" onclick="batchAction('disable')"
|
| 1524 |
+
disabled>批量禁用</button>
|
| 1525 |
+
<button class="batch-btn batch-delete" id="batchDeleteBtn" onclick="batchAction('delete')"
|
| 1526 |
+
disabled>批量删除</button>
|
| 1527 |
+
<button class="batch-btn" style="background-color: #ff9800;" id="batchVerifyBtn"
|
| 1528 |
+
onclick="batchVerifyProjectIds()" disabled>批量检验</button>
|
| 1529 |
+
<button class="batch-btn batch-email" onclick="refreshAllEmails()">刷新所有邮箱</button>
|
| 1530 |
+
<button class="batch-btn" style="background-color: #e91e63;"
|
| 1531 |
+
onclick="deduplicateByEmail()">凭证一键去重</button>
|
| 1532 |
+
</div>
|
| 1533 |
+
</div>
|
| 1534 |
+
|
| 1535 |
+
<!-- 筛选和分页控件 -->
|
| 1536 |
+
<div class="filter-container">
|
| 1537 |
+
<label for="statusFilter">凭证状态:</label>
|
| 1538 |
+
<select id="statusFilter" class="filter-select" onchange="applyStatusFilter()">
|
| 1539 |
+
<option value="all">全部凭证</option>
|
| 1540 |
+
<option value="enabled">仅启用</option>
|
| 1541 |
+
<option value="disabled">仅禁用</option>
|
| 1542 |
+
</select>
|
| 1543 |
+
|
| 1544 |
+
<label for="errorCodeFilter" style="margin-left: 20px;">错误码:</label>
|
| 1545 |
+
<select id="errorCodeFilter" class="filter-select" onchange="applyStatusFilter()">
|
| 1546 |
+
<option value="all">全部</option>
|
| 1547 |
+
<option value="400">400</option>
|
| 1548 |
+
<option value="403">403</option>
|
| 1549 |
+
<option value="429">429</option>
|
| 1550 |
+
<option value="500">500</option>
|
| 1551 |
+
</select>
|
| 1552 |
+
|
| 1553 |
+
<label for="cooldownFilter" style="margin-left: 20px;">冷却状态:</label>
|
| 1554 |
+
<select id="cooldownFilter" class="filter-select" onchange="applyStatusFilter()">
|
| 1555 |
+
<option value="all">全部</option>
|
| 1556 |
+
<option value="in_cooldown">CD中</option>
|
| 1557 |
+
<option value="no_cooldown">未CD</option>
|
| 1558 |
+
</select>
|
| 1559 |
+
|
| 1560 |
+
<label for="pageSizeSelect" style="margin-left: 20px;">每页显示:</label>
|
| 1561 |
+
<select id="pageSizeSelect" class="page-size-select" onchange="changePageSize()">
|
| 1562 |
+
<option value="20">20</option>
|
| 1563 |
+
<option value="50">50</option>
|
| 1564 |
+
<option value="100">100</option>
|
| 1565 |
+
<option value="200">200</option>
|
| 1566 |
+
<option value="500">500</option>
|
| 1567 |
+
<option value="1000">1000</option>
|
| 1568 |
+
</select>
|
| 1569 |
+
</div>
|
| 1570 |
+
|
| 1571 |
+
<div id="credsListSection">
|
| 1572 |
+
<div class="loading" id="credsLoading">正在加载凭证文件...</div>
|
| 1573 |
+
<div id="credsList"></div>
|
| 1574 |
+
|
| 1575 |
+
<!-- 分页控件 -->
|
| 1576 |
+
<div class="pagination-container" id="paginationContainer" style="display: none;">
|
| 1577 |
+
<button class="pagination-btn" id="prevPageBtn" onclick="changePage(-1)">上一页</button>
|
| 1578 |
+
<div class="pagination-info" id="paginationInfo">第 1 页,共 1 页</div>
|
| 1579 |
+
<button class="pagination-btn" id="nextPageBtn" onclick="changePage(1)">下一页</button>
|
| 1580 |
+
</div>
|
| 1581 |
+
</div>
|
| 1582 |
+
</div>
|
| 1583 |
+
|
| 1584 |
+
<!-- Antigravity 凭证管理标签页 -->
|
| 1585 |
+
<div id="antigravity-manageTab" class="tab-content">
|
| 1586 |
+
<h3>Antigravity凭证文件管理</h3>
|
| 1587 |
+
<p>管理所有Antigravity认证文件,查看状态和执行操作</p>
|
| 1588 |
+
|
| 1589 |
+
<!-- 检验功能说明 -->
|
| 1590 |
+
<div class="status info" style="margin-bottom: 20px;">
|
| 1591 |
+
<strong>💡 检验功能说明:</strong>
|
| 1592 |
+
<p style="margin: 10px 0;">
|
| 1593 |
+
点击每个凭证的"检验"按钮可以重新获取Project ID。<br>
|
| 1594 |
+
<strong style="color: #0c5460;">✅ 检验成功可以恢复403错误</strong>,让凭证重新正常工作。<br>
|
| 1595 |
+
建议在遇到403错误时使用此功能。
|
| 1596 |
+
</p>
|
| 1597 |
+
</div>
|
| 1598 |
+
|
| 1599 |
+
<!-- 状态统计 -->
|
| 1600 |
+
<div class="stats-container" id="antigravityStatsContainer">
|
| 1601 |
+
<div class="stat-item total">
|
| 1602 |
+
<span class="stat-number" id="antigravityStatTotal">0</span>
|
| 1603 |
+
<span class="stat-label">总计</span>
|
| 1604 |
+
</div>
|
| 1605 |
+
<div class="stat-item normal">
|
| 1606 |
+
<span class="stat-number" id="antigravityStatNormal">0</span>
|
| 1607 |
+
<span class="stat-label">正常</span>
|
| 1608 |
+
</div>
|
| 1609 |
+
<div class="stat-item disabled">
|
| 1610 |
+
<span class="stat-number" id="antigravityStatDisabled">0</span>
|
| 1611 |
+
<span class="stat-label">禁用</span>
|
| 1612 |
+
</div>
|
| 1613 |
+
</div>
|
| 1614 |
+
|
| 1615 |
+
<div class="manage-actions">
|
| 1616 |
+
<button class="refresh-btn" onclick="refreshAntigravityCredsList()">刷新状态</button>
|
| 1617 |
+
<button class="download-all-btn" onclick="downloadAllAntigravityCreds()">打包下载所有文件</button>
|
| 1618 |
+
</div>
|
| 1619 |
+
|
| 1620 |
+
<!-- 批量操作控件 -->
|
| 1621 |
+
<div class="batch-controls">
|
| 1622 |
+
<h4 style="margin-top: 0; margin-bottom: 10px;">批量操作</h4>
|
| 1623 |
+
<div class="batch-actions">
|
| 1624 |
+
<div class="checkbox-container">
|
| 1625 |
+
<input type="checkbox" id="selectAllAntigravityCheckbox" class="select-all-checkbox"
|
| 1626 |
+
onchange="toggleSelectAllAntigravity()">
|
| 1627 |
+
<label for="selectAllAntigravityCheckbox">全选</label>
|
| 1628 |
+
</div>
|
| 1629 |
+
<span class="selected-count" id="antigravitySelectedCount">已选择 0 项</span>
|
| 1630 |
+
<button class="batch-btn batch-enable" id="antigravityBatchEnableBtn"
|
| 1631 |
+
onclick="batchAntigravityAction('enable')" disabled>批量启用</button>
|
| 1632 |
+
<button class="batch-btn batch-disable" id="antigravityBatchDisableBtn"
|
| 1633 |
+
onclick="batchAntigravityAction('disable')" disabled>批量禁用</button>
|
| 1634 |
+
<button class="batch-btn batch-delete" id="antigravityBatchDeleteBtn"
|
| 1635 |
+
onclick="batchAntigravityAction('delete')" disabled>批量删除</button>
|
| 1636 |
+
<button class="batch-btn" style="background-color: #ff9800;" id="antigravityBatchVerifyBtn"
|
| 1637 |
+
onclick="batchVerifyAntigravityProjectIds()" disabled>批量检验</button>
|
| 1638 |
+
<button class="batch-btn batch-email" onclick="refreshAllAntigravityEmails()">刷新所有邮箱</button>
|
| 1639 |
+
<button class="batch-btn" style="background-color: #e91e63;"
|
| 1640 |
+
onclick="deduplicateAntigravityByEmail()">凭证一键去重</button>
|
| 1641 |
+
</div>
|
| 1642 |
+
</div>
|
| 1643 |
+
|
| 1644 |
+
<!-- 筛选和分页控件 -->
|
| 1645 |
+
<div class="filter-container">
|
| 1646 |
+
<label for="antigravityStatusFilter">凭证状态:</label>
|
| 1647 |
+
<select id="antigravityStatusFilter" class="filter-select"
|
| 1648 |
+
onchange="applyAntigravityStatusFilter()">
|
| 1649 |
+
<option value="all">全部凭证</option>
|
| 1650 |
+
<option value="enabled">仅启用</option>
|
| 1651 |
+
<option value="disabled">仅禁用</option>
|
| 1652 |
+
</select>
|
| 1653 |
+
|
| 1654 |
+
<label for="antigravityErrorCodeFilter" style="margin-left: 20px;">错误码:</label>
|
| 1655 |
+
<select id="antigravityErrorCodeFilter" class="filter-select"
|
| 1656 |
+
onchange="applyAntigravityStatusFilter()">
|
| 1657 |
+
<option value="all">全部</option>
|
| 1658 |
+
<option value="400">400</option>
|
| 1659 |
+
<option value="403">403</option>
|
| 1660 |
+
<option value="429">429</option>
|
| 1661 |
+
<option value="500">500</option>
|
| 1662 |
+
</select>
|
| 1663 |
+
|
| 1664 |
+
<label for="antigravityCooldownFilter" style="margin-left: 20px;">冷却状态:</label>
|
| 1665 |
+
<select id="antigravityCooldownFilter" class="filter-select"
|
| 1666 |
+
onchange="applyAntigravityStatusFilter()">
|
| 1667 |
+
<option value="all">全部</option>
|
| 1668 |
+
<option value="in_cooldown">CD中</option>
|
| 1669 |
+
<option value="no_cooldown">未CD</option>
|
| 1670 |
+
</select>
|
| 1671 |
+
|
| 1672 |
+
<label for="antigravityPageSizeSelect" style="margin-left: 20px;">每页显示:</label>
|
| 1673 |
+
<select id="antigravityPageSizeSelect" class="page-size-select"
|
| 1674 |
+
onchange="changeAntigravityPageSize()">
|
| 1675 |
+
<option value="20">20</option>
|
| 1676 |
+
<option value="50">50</option>
|
| 1677 |
+
<option value="100">100</option>
|
| 1678 |
+
<option value="200">200</option>
|
| 1679 |
+
<option value="500">500</option>
|
| 1680 |
+
<option value="1000">1000</option>
|
| 1681 |
+
</select>
|
| 1682 |
+
</div>
|
| 1683 |
+
|
| 1684 |
+
<div id="antigravityCredsListSection">
|
| 1685 |
+
<div class="loading" id="antigravityCredsLoading">正在加载凭证文件...</div>
|
| 1686 |
+
<div id="antigravityCredsList"></div>
|
| 1687 |
+
|
| 1688 |
+
<!-- 分页控件 -->
|
| 1689 |
+
<div class="pagination-container" id="antigravityPaginationContainer" style="display: none;">
|
| 1690 |
+
<button class="pagination-btn" id="antigravityPrevPageBtn"
|
| 1691 |
+
onclick="changeAntigravityPage(-1)">上一页</button>
|
| 1692 |
+
<div class="pagination-info" id="antigravityPaginationInfo">第 1 页,共 1 页</div>
|
| 1693 |
+
<button class="pagination-btn" id="antigravityNextPageBtn"
|
| 1694 |
+
onclick="changeAntigravityPage(1)">下一页</button>
|
| 1695 |
+
</div>
|
| 1696 |
+
</div>
|
| 1697 |
+
</div>
|
| 1698 |
+
|
| 1699 |
+
<!-- 配置管理标签页 -->
|
| 1700 |
+
<div id="configTab" class="tab-content">
|
| 1701 |
+
<h3>配置管理</h3>
|
| 1702 |
+
<p>管理系统配置参数,修改后立即生效</p>
|
| 1703 |
+
|
| 1704 |
+
<div class="manage-actions">
|
| 1705 |
+
<button class="refresh-btn" onclick="loadConfig()">刷新配置</button>
|
| 1706 |
+
<button class="btn" onclick="saveConfig()">保存配置</button>
|
| 1707 |
+
</div>
|
| 1708 |
+
|
| 1709 |
+
<div id="configSection">
|
| 1710 |
+
<div class="loading" id="configLoading">正在加载配置...</div>
|
| 1711 |
+
<div id="configForm" class="hidden">
|
| 1712 |
+
<div class="config-group">
|
| 1713 |
+
<h4>服务器配置</h4>
|
| 1714 |
+
|
| 1715 |
+
<div class="form-group">
|
| 1716 |
+
<label for="host">服务器主机地址:</label>
|
| 1717 |
+
<input type="text" id="host" class="config-input"
|
| 1718 |
+
placeholder="例如: 0.0.0.0, 127.0.0.1" />
|
| 1719 |
+
<small class="config-note">服务器监听的主机地址,0.0.0.0表示监听所有接口</small>
|
| 1720 |
+
</div>
|
| 1721 |
+
|
| 1722 |
+
<div class="form-group">
|
| 1723 |
+
<label for="port">服务器端口:</label>
|
| 1724 |
+
<input type="number" id="port" class="config-input" min="1" max="65535"
|
| 1725 |
+
placeholder="7861" />
|
| 1726 |
+
<small class="config-note">服务器监听的端口号,修改后需要重启服务器</small>
|
| 1727 |
+
</div>
|
| 1728 |
+
|
| 1729 |
+
<div class="form-group">
|
| 1730 |
+
<label for="configApiPassword">API访问密码:</label>
|
| 1731 |
+
<input type="text" id="configApiPassword" class="config-input" placeholder="pwd" />
|
| 1732 |
+
<small class="config-note">聊天API访问密码,用于OpenAI和Gemini API端点的认证</small>
|
| 1733 |
+
</div>
|
| 1734 |
+
|
| 1735 |
+
<div class="form-group">
|
| 1736 |
+
<label for="configPanelPassword">控制面板密码:</label>
|
| 1737 |
+
<input type="text" id="configPanelPassword" class="config-input" placeholder="pwd" />
|
| 1738 |
+
<small class="config-note">控制面板访问密码,用于web界面登录认证</small>
|
| 1739 |
+
</div>
|
| 1740 |
+
|
| 1741 |
+
<div class="form-group">
|
| 1742 |
+
<label for="configPassword">通用密码:</label>
|
| 1743 |
+
<input type="text" id="configPassword" class="config-input" placeholder="pwd" />
|
| 1744 |
+
<small class="config-note">(兼容性保留)设置后将覆盖上述两个密码,留空则使用分开的密码设置</small>
|
| 1745 |
+
</div>
|
| 1746 |
+
</div>
|
| 1747 |
+
|
| 1748 |
+
<div class="config-group">
|
| 1749 |
+
<h4>基础配置</h4>
|
| 1750 |
+
|
| 1751 |
+
<div class="form-group">
|
| 1752 |
+
<label for="credentialsDir">凭证目录路径:</label>
|
| 1753 |
+
<input type="text" id="credentialsDir" class="config-input" />
|
| 1754 |
+
<small class="config-note">存储认证文件的目录路径</small>
|
| 1755 |
+
</div>
|
| 1756 |
+
|
| 1757 |
+
<div class="form-group">
|
| 1758 |
+
<label for="proxy">代理设置:</label>
|
| 1759 |
+
<input type="text" id="proxy" class="config-input"
|
| 1760 |
+
placeholder="例如: http://proxy:11451 或 socks5://proxy:1080" />
|
| 1761 |
+
<small class="config-note">HTTP/HTTPS/SOCKS5Endpoint,留空表示不使用代理</small>
|
| 1762 |
+
</div>
|
| 1763 |
+
</div>
|
| 1764 |
+
|
| 1765 |
+
<div class="config-group">
|
| 1766 |
+
<h4>端点配置</h4>
|
| 1767 |
+
|
| 1768 |
+
<!-- 快速配置按钮 -->
|
| 1769 |
+
<div class="form-group">
|
| 1770 |
+
<div style="display: flex; gap: 10px; margin-bottom: 15px; flex-wrap: wrap;">
|
| 1771 |
+
<button type="button" class="btn" onclick="useMirrorUrls()"
|
| 1772 |
+
style="background-color: #28a745; font-size: 14px;">
|
| 1773 |
+
🚀 一键使用镜像网址
|
| 1774 |
+
</button>
|
| 1775 |
+
<button type="button" class="btn" onclick="restoreOfficialUrls()"
|
| 1776 |
+
style="background-color: #17a2b8; font-size: 14px;">
|
| 1777 |
+
🔄 还原官方端点
|
| 1778 |
+
</button>
|
| 1779 |
+
</div>
|
| 1780 |
+
<small class="config-note">镜像网址主要解决墙内无法访问官方端点的问题,部分地区可能无法使用</small>
|
| 1781 |
+
</div>
|
| 1782 |
+
|
| 1783 |
+
<div class="form-group">
|
| 1784 |
+
<label for="codeAssistEndpoint">Code Assist Endpoint:</label>
|
| 1785 |
+
<input type="text" id="codeAssistEndpoint" class="config-input" />
|
| 1786 |
+
<small class="config-note">Google Cloud Code Assist API端点地址</small>
|
| 1787 |
+
</div>
|
| 1788 |
+
<div class="form-group">
|
| 1789 |
+
<label for="oauthProxyUrl">OAuth Endpoint:</label>
|
| 1790 |
+
<input type="text" id="oauthProxyUrl" class="config-input"
|
| 1791 |
+
placeholder="https://oauth2.googleapis.com" />
|
| 1792 |
+
<small class="config-note">Google OAuth2 API端点地址,用于token获取和刷新</small>
|
| 1793 |
+
</div>
|
| 1794 |
+
<div class="form-group">
|
| 1795 |
+
<label for="googleapisProxyUrl">Google APIs Endpoint:</label>
|
| 1796 |
+
<input type="text" id="googleapisProxyUrl" class="config-input"
|
| 1797 |
+
placeholder="https://www.googleapis.com" />
|
| 1798 |
+
<small class="config-note">Google APIs API端点地址,用于API服务调用</small>
|
| 1799 |
+
</div>
|
| 1800 |
+
<div class="form-group">
|
| 1801 |
+
<label for="resourceManagerApiUrl">Resource Manager API Endpoint:</label>
|
| 1802 |
+
<input type="text" id="resourceManagerApiUrl" class="config-input"
|
| 1803 |
+
placeholder="https://cloudresourcemanager.googleapis.com" />
|
| 1804 |
+
<small class="config-note">Google Cloud Resource Manager API端点地址,用于项目管理</small>
|
| 1805 |
+
</div>
|
| 1806 |
+
<div class="form-group">
|
| 1807 |
+
<label for="serviceUsageApiUrl">Service Usage API Endpoint:</label>
|
| 1808 |
+
<input type="text" id="serviceUsageApiUrl" class="config-input"
|
| 1809 |
+
placeholder="https://serviceusage.googleapis.com" />
|
| 1810 |
+
<small class="config-note">Google Cloud Service Usage API端点地址,用于服务启用管理</small>
|
| 1811 |
+
</div>
|
| 1812 |
+
<div class="form-group">
|
| 1813 |
+
<label for="antigravityApiUrl">Antigravity API Endpoint:</label>
|
| 1814 |
+
<input type="text" id="antigravityApiUrl" class="config-input"
|
| 1815 |
+
placeholder="https://daily-cloudcode-pa.sandbox.googleapis.com" />
|
| 1816 |
+
<small class="config-note">Google Antigravity API端点地址,用于反重力模式</small>
|
| 1817 |
+
</div>
|
| 1818 |
+
</div>
|
| 1819 |
+
|
| 1820 |
+
<div class="config-group">
|
| 1821 |
+
<h4>自动封禁配置</h4>
|
| 1822 |
+
|
| 1823 |
+
<div class="form-group">
|
| 1824 |
+
<label>
|
| 1825 |
+
<input type="checkbox" id="autoBanEnabled" class="config-checkbox" />
|
| 1826 |
+
启用自动封禁
|
| 1827 |
+
</label>
|
| 1828 |
+
<small class="config-note">遇到指定错误码时自动禁用凭证</small>
|
| 1829 |
+
</div>
|
| 1830 |
+
|
| 1831 |
+
<div class="form-group">
|
| 1832 |
+
<label for="autoBanErrorCodes">自动封禁错误码:</label>
|
| 1833 |
+
<input type="text" id="autoBanErrorCodes" class="config-input"
|
| 1834 |
+
placeholder="例如: 400,403" />
|
| 1835 |
+
<small class="config-note">用逗号分隔的错误码列表</small>
|
| 1836 |
+
</div>
|
| 1837 |
+
</div>
|
| 1838 |
+
|
| 1839 |
+
<div class="config-group">
|
| 1840 |
+
<h4>429重试配置</h4>
|
| 1841 |
+
|
| 1842 |
+
<div class="form-group">
|
| 1843 |
+
<label>
|
| 1844 |
+
<input type="checkbox" id="retry429Enabled" class="config-checkbox" />
|
| 1845 |
+
启用429重试
|
| 1846 |
+
</label>
|
| 1847 |
+
<small class="config-note">遇到429错误时自动重试</small>
|
| 1848 |
+
</div>
|
| 1849 |
+
|
| 1850 |
+
<div class="form-group">
|
| 1851 |
+
<label for="retry429MaxRetries">429重试次数:</label>
|
| 1852 |
+
<input type="number" id="retry429MaxRetries" class="config-input" min="1" max="50" />
|
| 1853 |
+
<small class="config-note">遇到429错误时的最大重试次数</small>
|
| 1854 |
+
</div>
|
| 1855 |
+
|
| 1856 |
+
<div class="form-group">
|
| 1857 |
+
<label for="retry429Interval">429重试间隔(秒):</label>
|
| 1858 |
+
<input type="number" id="retry429Interval" class="config-input" min="0.01" max="10"
|
| 1859 |
+
step="0.01" />
|
| 1860 |
+
<small class="config-note">遇到429错误时每两次重试间的等待时间</small>
|
| 1861 |
+
</div>
|
| 1862 |
+
</div>
|
| 1863 |
+
|
| 1864 |
+
|
| 1865 |
+
<div class="config-group">
|
| 1866 |
+
<h4>兼容性配置</h4>
|
| 1867 |
+
|
| 1868 |
+
<div class="form-group">
|
| 1869 |
+
<label>
|
| 1870 |
+
<input type="checkbox" id="compatibilityModeEnabled" class="config-checkbox" />
|
| 1871 |
+
启用兼容性模式
|
| 1872 |
+
</label>
|
| 1873 |
+
<small class="config-note">启用后所有system消息全部转换成user,停用system_instructions <span
|
| 1874 |
+
style="color: #28a745;">✓ 支持热更新</span></small>
|
| 1875 |
+
<div class="config-info"
|
| 1876 |
+
style="background-color: #fff3cd; border: 1px solid #ffc107; color: #856404;">
|
| 1877 |
+
<strong>⚠️ 注意:</strong>该选项可能会降低模型理解能力,但是能避免流式空回的情况。
|
| 1878 |
+
<br><strong>适用场景:</strong>当遇到流式传输时模型不返回内容或返回空响应时启用此选项。
|
| 1879 |
+
</div>
|
| 1880 |
+
</div>
|
| 1881 |
+
|
| 1882 |
+
<div class="form-group">
|
| 1883 |
+
<label>
|
| 1884 |
+
<input type="checkbox" id="returnThoughtsToFrontend" class="config-checkbox" />
|
| 1885 |
+
返回思维链到前端
|
| 1886 |
+
</label>
|
| 1887 |
+
<small class="config-note">启用后,模型的思维链会在响应中返回;禁用后,思维链会被过滤掉 <span
|
| 1888 |
+
style="color: #28a745;">✓ 支持热更新</span></small>
|
| 1889 |
+
<div class="config-info"
|
| 1890 |
+
style="background-color: #e3f2fd; border: 1px solid #2196f3; color: #0d47a1;">
|
| 1891 |
+
<strong>💭 说明:</strong>某些模型(如Gemini 2.0
|
| 1892 |
+
Pro)支持thinking模式,会在生成回答前先输出思考过程。启用后可以看到模型的思考过程;禁用后只显示最终回答,让输出更简洁。
|
| 1893 |
+
</div>
|
| 1894 |
+
</div>
|
| 1895 |
+
|
| 1896 |
+
<div class="form-group">
|
| 1897 |
+
<label>
|
| 1898 |
+
<input type="checkbox" id="antigravityStream2nostream" class="config-checkbox" />
|
| 1899 |
+
Antigravity流式转非流式
|
| 1900 |
+
</label>
|
| 1901 |
+
<small class="config-note">启用后,非流式请求将使用流式API并收集为完整响应 <span
|
| 1902 |
+
style="color: #28a745;">✓ 支持热更新</span></small>
|
| 1903 |
+
<div class="config-info"
|
| 1904 |
+
style="background-color: #f3e5f5; border: 1px solid #9c27b0; color: #4a148c;">
|
| 1905 |
+
<strong>🔄 说明:</strong>针对Antigravity模式的优化选项。启用后,即使客户端请求非流式响应,后端也会使用流式API获取数据并收集完整后再返回。
|
| 1906 |
+
<br><strong>适用场景:</strong>某些情况下流式API比非流式API更稳定,启用此选项可以提高响应质量。
|
| 1907 |
+
<br><strong>默认:</strong>已启用
|
| 1908 |
+
</div>
|
| 1909 |
+
</div>
|
| 1910 |
+
</div>
|
| 1911 |
+
|
| 1912 |
+
<div class="config-group">
|
| 1913 |
+
<h4>抗截断配置</h4>
|
| 1914 |
+
|
| 1915 |
+
<div class="form-group">
|
| 1916 |
+
<label for="antiTruncationMaxAttempts">抗截断最大重试次数:</label>
|
| 1917 |
+
<input type="number" id="antiTruncationMaxAttempts" class="config-input" min="1"
|
| 1918 |
+
max="10" />
|
| 1919 |
+
<small class="config-note">当检测到输出截断时的最大续传尝试次数</small>
|
| 1920 |
+
</div>
|
| 1921 |
+
|
| 1922 |
+
<div class="form-group">
|
| 1923 |
+
<div class="config-info">
|
| 1924 |
+
<strong>注意:</strong>抗截断功能现在通过模型名控制:
|
| 1925 |
+
<ul style="margin: 5px 0; padding-left: 20px;">
|
| 1926 |
+
<li>选择带有 "-流式抗截断" 后缀的模型即可启用</li>
|
| 1927 |
+
<li>该功能仅在流式传输时生效</li>
|
| 1928 |
+
<li>例如: "gemini-2.5-pro-流式抗截断"</li>
|
| 1929 |
+
</ul>
|
| 1930 |
+
</div>
|
| 1931 |
+
</div>
|
| 1932 |
+
</div>
|
| 1933 |
+
|
| 1934 |
+
<div class="config-group">
|
| 1935 |
+
<h4>配置热更新说明</h4>
|
| 1936 |
+
|
| 1937 |
+
<div class="form-group">
|
| 1938 |
+
<div class="config-info"
|
| 1939 |
+
style="background-color: #d4edda; border: 1px solid #c3e6cb; color: #155724;">
|
| 1940 |
+
<strong>🔥 热更新配置(立即生效):</strong>
|
| 1941 |
+
<ul style="margin: 8px 0; padding-left: 20px; color: #212529;">
|
| 1942 |
+
<li><strong>网络配置:</strong>代理设置、端点配置、HTTP超时时间、最大连接数</li>
|
| 1943 |
+
<li><strong>API配置:</strong>凭证轮换次数、429重试设置、自动封禁配置</li>
|
| 1944 |
+
<li><strong>密码配置:</strong>API密码、控制面板密码、通用密码</li>
|
| 1945 |
+
<li><strong>功能配置:</strong>抗截断最大重试次数</li>
|
| 1946 |
+
</ul>
|
| 1947 |
+
</div>
|
| 1948 |
+
</div>
|
| 1949 |
+
|
| 1950 |
+
<div class="form-group">
|
| 1951 |
+
<div class="config-info"
|
| 1952 |
+
style="background-color: #fff3cd; border: 1px solid #ffc107; color: #856404;">
|
| 1953 |
+
<strong>🔄 需要重启的配置:</strong>
|
| 1954 |
+
<ul style="margin: 8px 0; padding-left: 20px; color: #212529;">
|
| 1955 |
+
<li><strong>服务器配置:</strong>主机地址、端口号</li>
|
| 1956 |
+
<li><strong>目录配置:</strong>凭证目录路径、Code Assist端点</li>
|
| 1957 |
+
</ul>
|
| 1958 |
+
</div>
|
| 1959 |
+
</div>
|
| 1960 |
+
|
| 1961 |
+
</div>
|
| 1962 |
+
</div>
|
| 1963 |
+
</div>
|
| 1964 |
+
</div>
|
| 1965 |
+
|
| 1966 |
+
<!-- 实时日志标签页 -->
|
| 1967 |
+
<div id="logsTab" class="tab-content">
|
| 1968 |
+
<h3>实时日志</h3>
|
| 1969 |
+
<p>查看系统实时日志输出,支持日志筛选和自动滚动</p>
|
| 1970 |
+
|
| 1971 |
+
<div class="manage-actions">
|
| 1972 |
+
<button class="refresh-btn" onclick="connectWebSocket()">连接日志流</button>
|
| 1973 |
+
<button class="btn" style="background-color: #dc3545;" onclick="disconnectWebSocket()">断开连接</button>
|
| 1974 |
+
<button class="btn" style="background-color: #28a745;" onclick="downloadLogs()">下载日志</button>
|
| 1975 |
+
<button class="btn" style="background-color: #6c757d;" onclick="clearLogs()">清空日志</button>
|
| 1976 |
+
</div>
|
| 1977 |
+
|
| 1978 |
+
<div class="filter-container">
|
| 1979 |
+
<label for="logLevelFilter">日志级别筛选:</label>
|
| 1980 |
+
<select id="logLevelFilter" class="filter-select" onchange="filterLogs()">
|
| 1981 |
+
<option value="all">全部</option>
|
| 1982 |
+
<option value="ERROR">错误</option>
|
| 1983 |
+
<option value="WARNING">警告</option>
|
| 1984 |
+
<option value="INFO">信息</option>
|
| 1985 |
+
<option value="DEBUG">调试</option>
|
| 1986 |
+
</select>
|
| 1987 |
+
|
| 1988 |
+
<label>
|
| 1989 |
+
<input type="checkbox" id="autoScroll" checked> 自动滚动到底部
|
| 1990 |
+
</label>
|
| 1991 |
+
</div>
|
| 1992 |
+
|
| 1993 |
+
<div id="logConnectionStatus" class="status info">
|
| 1994 |
+
<strong>连接状态:</strong> <span id="connectionStatusText">未连接</span>
|
| 1995 |
+
</div>
|
| 1996 |
+
|
| 1997 |
+
<div id="logContainer"
|
| 1998 |
+
style="background-color: #1e1e1e; color: #ffffff; font-family: 'Courier New', monospace; font-size: 12px; height: 600px; overflow-y: auto; border: 1px solid #333; border-radius: 5px; padding: 15px; white-space: pre-wrap; word-break: break-all;">
|
| 1999 |
+
<div id="logContent">等待连接日志流...</div>
|
| 2000 |
+
</div>
|
| 2001 |
+
</div>
|
| 2002 |
+
|
| 2003 |
+
<!-- 项目信息标签页 -->
|
| 2004 |
+
<div id="aboutTab" class="tab-content">
|
| 2005 |
+
<h3>项目信息</h3>
|
| 2006 |
+
<p>关于GCLI2API项目的详细信息和支持方式</p>
|
| 2007 |
+
|
| 2008 |
+
<!-- 项目介绍 -->
|
| 2009 |
+
<div
|
| 2010 |
+
style="background-color: #f8f9fa; padding: 20px; border-radius: 10px; margin: 20px 0; border-left: 4px solid #007bff;">
|
| 2011 |
+
<h4 style="margin-top: 0; color: #007bff;">📋 项目简介</h4>
|
| 2012 |
+
<p style="margin: 10px 0; line-height: 1.6; color: #495057;">
|
| 2013 |
+
GCLI2API是一个将Google Gemini API转换为OpenAI 和GEMINI API格式的代理工具,支持多账户管理、自动轮换、实时日志监控等功能。
|
| 2014 |
+
</p>
|
| 2015 |
+
<div style="margin: 15px 0;">
|
| 2016 |
+
<p style="margin: 5px 0;"><strong>🔗 项目地址:</strong> <a
|
| 2017 |
+
href="https://github.com/su-kaka/gcli2api" target="_blank"
|
| 2018 |
+
style="color: #007bff; text-decoration: none;">GitHub - su-kaka/gcli2api</a></p>
|
| 2019 |
+
<p style="margin: 5px 0;"><strong>⚠️ 使用声明:</strong> <span
|
| 2020 |
+
style="color: #dc3545; font-weight: 500;">禁止商业用途和倒卖 - 仅供学习使用</span></p>
|
| 2021 |
+
</div>
|
| 2022 |
+
</div>
|
| 2023 |
+
|
| 2024 |
+
<!-- 功能特性 -->
|
| 2025 |
+
<div
|
| 2026 |
+
style="background-color: #e7f3ff; padding: 20px; border-radius: 10px; margin: 20px 0; border-left: 4px solid #17a2b8;">
|
| 2027 |
+
<h4 style="margin-top: 0; color: #17a2b8;">✨ 主要功能</h4>
|
| 2028 |
+
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 15px;">
|
| 2029 |
+
<div>
|
| 2030 |
+
<p><strong>🔄 多账户管理:</strong> 支持批量上传和管理多个Google账户</p>
|
| 2031 |
+
<p><strong>⚡ 自动轮换:</strong> 智能轮换账户,避免单账户限额</p>
|
| 2032 |
+
<p><strong>📊 实时监控:</strong> 使用统计、错误监控、实时日志</p>
|
| 2033 |
+
</div>
|
| 2034 |
+
<div>
|
| 2035 |
+
<p><strong>🛡️ 安全可靠:</strong> OAuth2认证、自动封禁异常账户</p>
|
| 2036 |
+
<p><strong>🎛️ 配置灵活:</strong> 支持热更新配置、代理设置</p>
|
| 2037 |
+
<p><strong>📱 界面友好:</strong> 响应式设计、移动端适配</p>
|
| 2038 |
+
</div>
|
| 2039 |
+
</div>
|
| 2040 |
+
</div>
|
| 2041 |
+
|
| 2042 |
+
<!-- 交流群 -->
|
| 2043 |
+
<div
|
| 2044 |
+
style="background-color: #e7f3ff; padding: 20px; border-radius: 10px; margin: 20px 0; border-left: 4px solid #4285f4; text-align: center;">
|
| 2045 |
+
<h4 style="margin-top: 0; color: #1976d2;">💬 交流群</h4>
|
| 2046 |
+
<div style="color: #1565c0; line-height: 1.6;">
|
| 2047 |
+
<p>欢迎加入 QQ 群交流讨论!</p>
|
| 2048 |
+
<p style="font-size: 18px; font-weight: bold; color: #4285f4;">QQ 群号:937681997</p>
|
| 2049 |
+
</div>
|
| 2050 |
+
<div
|
| 2051 |
+
style="display: inline-block; background: white; padding: 15px; border-radius: 12px; box-shadow: 0 2px 15px rgba(0,0,0,0.1); margin-top: 10px;">
|
| 2052 |
+
<img src="docs/qq群.jpg" alt="QQ群二维码"
|
| 2053 |
+
style="width: 200px; height: 200px; border-radius: 8px; display: block;">
|
| 2054 |
+
<p
|
| 2055 |
+
style="color: #666; margin: 10px 0 0 0; font-size: 13px; font-weight: 600; text-align: center;">
|
| 2056 |
+
扫码加入QQ群</p>
|
| 2057 |
+
</div>
|
| 2058 |
+
</div>
|
| 2059 |
+
|
| 2060 |
+
<!-- 联系和反馈 -->
|
| 2061 |
+
<div
|
| 2062 |
+
style="background-color: #d1ecf1; padding: 20px; border-radius: 10px; margin: 20px 0; border-left: 4px solid #bee5eb;">
|
| 2063 |
+
<h4 style="margin-top: 0; color: #0c5460;">📞 联系我们</h4>
|
| 2064 |
+
<div style="color: #0c5460; line-height: 1.6;">
|
| 2065 |
+
<p>• <strong>问题反馈:</strong> 通过GitHub Issues提交问题和建议</p>
|
| 2066 |
+
<p>• <strong>功能请求:</strong> 在GitHub Discussions中讨论新功能</p>
|
| 2067 |
+
<p>• <strong>代码贡献:</strong> 欢迎提交Pull Request改进项目</p>
|
| 2068 |
+
<p>• <strong>文档完善:</strong> 帮助改进项目文档和使用指南</p>
|
| 2069 |
+
</div>
|
| 2070 |
+
</div>
|
| 2071 |
+
</div>
|
| 2072 |
+
|
| 2073 |
+
<div id="statusSection"></div>
|
| 2074 |
+
|
| 2075 |
+
<!-- 项目信息 -->
|
| 2076 |
+
<div
|
| 2077 |
+
style="background-color: #f8f9fa; border: 1px solid #dee2e6; border-radius: 8px; padding: 12px; margin-top: 30px; text-align: center; border-left: 4px solid #007bff;">
|
| 2078 |
+
<p style="margin: 5px 0; font-size: 14px; color: #495057;">GitHub: <a
|
| 2079 |
+
href="https://github.com/su-kaka/gcli2api" target="_blank"
|
| 2080 |
+
style="color: #007bff; text-decoration: none;">https://github.com/su-kaka/gcli2api</a></p>
|
| 2081 |
+
<p style="margin: 5px 0; font-size: 14px; color: #dc3545; font-weight: 500;">⚠️ 禁止商业用途和倒卖 - 仅供学习使用 ⚠️
|
| 2082 |
+
</p>
|
| 2083 |
+
</div>
|
| 2084 |
+
</div>
|
| 2085 |
+
</div>
|
| 2086 |
+
|
| 2087 |
+
<!-- 引入公共JavaScript模块 -->
|
| 2088 |
+
<script src="./front/common.js"></script>
|
| 2089 |
+
|
| 2090 |
+
</body>
|
| 2091 |
+
|
| 2092 |
+
</html>
|
front/control_panel_mobile.html
ADDED
|
@@ -0,0 +1,1822 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="zh-CN">
|
| 3 |
+
|
| 4 |
+
<head>
|
| 5 |
+
<meta charset="UTF-8">
|
| 6 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
|
| 7 |
+
<title>GCLI2API 移动端控制面板</title>
|
| 8 |
+
<style>
|
| 9 |
+
* {
|
| 10 |
+
box-sizing: border-box;
|
| 11 |
+
-webkit-tap-highlight-color: transparent;
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
body {
|
| 15 |
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
|
| 16 |
+
margin: 0;
|
| 17 |
+
padding: 0;
|
| 18 |
+
background-color: #f5f5f5;
|
| 19 |
+
font-size: 16px;
|
| 20 |
+
line-height: 1.5;
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
.container {
|
| 24 |
+
max-width: 100%;
|
| 25 |
+
margin: 0;
|
| 26 |
+
padding: 10px;
|
| 27 |
+
background-color: white;
|
| 28 |
+
min-height: 100vh;
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
h1 {
|
| 32 |
+
color: #333;
|
| 33 |
+
text-align: center;
|
| 34 |
+
margin: 10px 0 20px 0;
|
| 35 |
+
font-size: 20px;
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
.form-group {
|
| 39 |
+
margin-bottom: 16px;
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
label {
|
| 43 |
+
display: block;
|
| 44 |
+
margin-bottom: 6px;
|
| 45 |
+
font-weight: bold;
|
| 46 |
+
color: #555;
|
| 47 |
+
font-size: 14px;
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
input[type="text"],
|
| 51 |
+
input[type="password"],
|
| 52 |
+
input[type="number"],
|
| 53 |
+
select,
|
| 54 |
+
textarea {
|
| 55 |
+
width: 100%;
|
| 56 |
+
padding: 12px;
|
| 57 |
+
border: 2px solid #ddd;
|
| 58 |
+
border-radius: 8px;
|
| 59 |
+
font-size: 16px;
|
| 60 |
+
background-color: white;
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
input:focus,
|
| 64 |
+
select:focus,
|
| 65 |
+
textarea:focus {
|
| 66 |
+
border-color: #4285f4;
|
| 67 |
+
outline: none;
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
.btn {
|
| 71 |
+
background-color: #4285f4;
|
| 72 |
+
color: white;
|
| 73 |
+
padding: 14px 20px;
|
| 74 |
+
border: none;
|
| 75 |
+
border-radius: 8px;
|
| 76 |
+
font-size: 16px;
|
| 77 |
+
cursor: pointer;
|
| 78 |
+
width: 100%;
|
| 79 |
+
margin-bottom: 10px;
|
| 80 |
+
touch-action: manipulation;
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
.btn:hover {
|
| 84 |
+
background-color: #3367d6;
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
.btn:disabled {
|
| 88 |
+
background-color: #ccc;
|
| 89 |
+
cursor: not-allowed;
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
.btn-small {
|
| 93 |
+
padding: 8px 12px;
|
| 94 |
+
font-size: 14px;
|
| 95 |
+
width: auto;
|
| 96 |
+
display: inline-block;
|
| 97 |
+
margin: 2px;
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
/* 移动端优化的标签页 */
|
| 101 |
+
.tabs {
|
| 102 |
+
display: flex;
|
| 103 |
+
background: linear-gradient(145deg, #f5f5f7, #e8e8ed);
|
| 104 |
+
padding: 5px;
|
| 105 |
+
border-radius: 12px;
|
| 106 |
+
margin-bottom: 20px;
|
| 107 |
+
overflow-x: auto;
|
| 108 |
+
-webkit-overflow-scrolling: touch;
|
| 109 |
+
gap: 3px;
|
| 110 |
+
border-bottom: none;
|
| 111 |
+
user-select: none;
|
| 112 |
+
width: 100%;
|
| 113 |
+
box-sizing: border-box;
|
| 114 |
+
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.06),
|
| 115 |
+
0 1px 2px rgba(255, 255, 255, 0.9);
|
| 116 |
+
position: relative;
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
/* 滑块指示器 */
|
| 120 |
+
.tab-slider {
|
| 121 |
+
position: absolute;
|
| 122 |
+
top: 5px;
|
| 123 |
+
left: 0;
|
| 124 |
+
right: 0;
|
| 125 |
+
height: calc(100% - 10px);
|
| 126 |
+
background: white;
|
| 127 |
+
border-radius: 9px;
|
| 128 |
+
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1),
|
| 129 |
+
0 1px 3px rgba(0, 0, 0, 0.06);
|
| 130 |
+
transition: left 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
| 131 |
+
right 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
| 132 |
+
z-index: 0;
|
| 133 |
+
pointer-events: none;
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
/* 滑块微光效果 */
|
| 137 |
+
.tab-slider::after {
|
| 138 |
+
content: '';
|
| 139 |
+
position: absolute;
|
| 140 |
+
top: 0;
|
| 141 |
+
left: 0;
|
| 142 |
+
right: 0;
|
| 143 |
+
height: 50%;
|
| 144 |
+
background: linear-gradient(180deg,
|
| 145 |
+
rgba(255, 255, 255, 0.8) 0%,
|
| 146 |
+
rgba(255, 255, 255, 0) 100%);
|
| 147 |
+
border-radius: 9px 9px 0 0;
|
| 148 |
+
pointer-events: none;
|
| 149 |
+
opacity: 0.5;
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
.tabs::-webkit-scrollbar {
|
| 153 |
+
height: 3px;
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
.tabs::-webkit-scrollbar-track {
|
| 157 |
+
background: transparent;
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
.tabs::-webkit-scrollbar-thumb {
|
| 161 |
+
background: rgba(0, 0, 0, 0.15);
|
| 162 |
+
border-radius: 2px;
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
.tab {
|
| 166 |
+
padding: 10px 14px;
|
| 167 |
+
cursor: pointer;
|
| 168 |
+
border: none;
|
| 169 |
+
background: transparent;
|
| 170 |
+
border-radius: 9px;
|
| 171 |
+
white-space: nowrap;
|
| 172 |
+
min-width: 70px;
|
| 173 |
+
flex-shrink: 0;
|
| 174 |
+
font-size: 13px;
|
| 175 |
+
font-weight: 450;
|
| 176 |
+
color: #666;
|
| 177 |
+
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
| 178 |
+
position: relative;
|
| 179 |
+
overflow: hidden;
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
/* 点击波纹效果 */
|
| 183 |
+
.tab::before {
|
| 184 |
+
content: '';
|
| 185 |
+
position: absolute;
|
| 186 |
+
top: 50%;
|
| 187 |
+
left: 50%;
|
| 188 |
+
width: 0;
|
| 189 |
+
height: 0;
|
| 190 |
+
border-radius: 50%;
|
| 191 |
+
background: rgba(66, 133, 244, 0.15);
|
| 192 |
+
transform: translate(-50%, -50%);
|
| 193 |
+
transition: width 0.4s ease, height 0.4s ease;
|
| 194 |
+
z-index: -1;
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
.tab:active::before {
|
| 198 |
+
width: 200%;
|
| 199 |
+
height: 200%;
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
.tab.active {
|
| 203 |
+
color: #1a1a1a;
|
| 204 |
+
font-weight: 550;
|
| 205 |
+
/* 背景和阴影由滑块提供 */
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
.tab:hover:not(.active) {
|
| 209 |
+
background: rgba(255, 255, 255, 0.5);
|
| 210 |
+
color: #333;
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
/* 按压效果 */
|
| 214 |
+
.tab:active {
|
| 215 |
+
transform: scale(0.96);
|
| 216 |
+
transition: transform 0.1s ease;
|
| 217 |
+
}
|
| 218 |
+
|
| 219 |
+
.tab.active:active {
|
| 220 |
+
transform: scale(0.98);
|
| 221 |
+
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
.tab-content {
|
| 225 |
+
display: none;
|
| 226 |
+
padding: 10px 0;
|
| 227 |
+
/* 动画由 JavaScript 控制,避免冲突 */
|
| 228 |
+
}
|
| 229 |
+
|
| 230 |
+
.tab-content.active {
|
| 231 |
+
display: block;
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
+
/* Toast 固定定位在右上角 */
|
| 235 |
+
#statusSection {
|
| 236 |
+
position: fixed;
|
| 237 |
+
top: 10px;
|
| 238 |
+
right: 10px;
|
| 239 |
+
left: auto;
|
| 240 |
+
transform: none;
|
| 241 |
+
z-index: 9999;
|
| 242 |
+
width: auto;
|
| 243 |
+
max-width: 90%;
|
| 244 |
+
min-width: 200px;
|
| 245 |
+
}
|
| 246 |
+
|
| 247 |
+
/* Toast 专用样式 - 只在 #statusSection 内生效 */
|
| 248 |
+
#statusSection .status {
|
| 249 |
+
padding: 10px 16px;
|
| 250 |
+
border-radius: 8px;
|
| 251 |
+
margin: 0;
|
| 252 |
+
font-size: 13px;
|
| 253 |
+
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
|
| 254 |
+
opacity: 0;
|
| 255 |
+
transform: translateX(100%);
|
| 256 |
+
transition: opacity 0.3s ease, transform 0.3s ease;
|
| 257 |
+
}
|
| 258 |
+
|
| 259 |
+
#statusSection .status.show {
|
| 260 |
+
opacity: 1;
|
| 261 |
+
transform: translateX(0);
|
| 262 |
+
}
|
| 263 |
+
|
| 264 |
+
#statusSection .status.fade-out {
|
| 265 |
+
opacity: 0;
|
| 266 |
+
transform: translateX(100%);
|
| 267 |
+
}
|
| 268 |
+
|
| 269 |
+
#statusSection .status.success {
|
| 270 |
+
background-color: #28a745;
|
| 271 |
+
border: none;
|
| 272 |
+
color: white;
|
| 273 |
+
}
|
| 274 |
+
|
| 275 |
+
#statusSection .status.error {
|
| 276 |
+
background-color: #dc3545;
|
| 277 |
+
border: none;
|
| 278 |
+
color: white;
|
| 279 |
+
}
|
| 280 |
+
|
| 281 |
+
#statusSection .status.warning {
|
| 282 |
+
background-color: #ffc107;
|
| 283 |
+
border: none;
|
| 284 |
+
color: #212529;
|
| 285 |
+
}
|
| 286 |
+
|
| 287 |
+
#statusSection .status.info {
|
| 288 |
+
background-color: #17a2b8;
|
| 289 |
+
border: none;
|
| 290 |
+
color: white;
|
| 291 |
+
}
|
| 292 |
+
|
| 293 |
+
/* 页面内嵌的 status 样式 - 保持原有风格 */
|
| 294 |
+
.status {
|
| 295 |
+
padding: 12px;
|
| 296 |
+
border-radius: 8px;
|
| 297 |
+
margin: 10px 0;
|
| 298 |
+
font-size: 14px;
|
| 299 |
+
}
|
| 300 |
+
|
| 301 |
+
.status.success {
|
| 302 |
+
background-color: #d4edda;
|
| 303 |
+
border: 1px solid #c3e6cb;
|
| 304 |
+
color: #155724;
|
| 305 |
+
}
|
| 306 |
+
|
| 307 |
+
.status.error {
|
| 308 |
+
background-color: #f8d7da;
|
| 309 |
+
border: 1px solid #f5c6cb;
|
| 310 |
+
color: #721c24;
|
| 311 |
+
}
|
| 312 |
+
|
| 313 |
+
.status.info {
|
| 314 |
+
background-color: #d1ecf1;
|
| 315 |
+
border: 1px solid #bee5eb;
|
| 316 |
+
color: #0c5460;
|
| 317 |
+
}
|
| 318 |
+
|
| 319 |
+
.hidden {
|
| 320 |
+
display: none;
|
| 321 |
+
}
|
| 322 |
+
|
| 323 |
+
.loading {
|
| 324 |
+
text-align: center;
|
| 325 |
+
color: #666;
|
| 326 |
+
padding: 20px;
|
| 327 |
+
}
|
| 328 |
+
|
| 329 |
+
.login-form {
|
| 330 |
+
text-align: center;
|
| 331 |
+
padding: 40px 20px;
|
| 332 |
+
}
|
| 333 |
+
|
| 334 |
+
/* 移动端优化的卡片样式 */
|
| 335 |
+
.card {
|
| 336 |
+
background-color: white;
|
| 337 |
+
border: 1px solid #e1e4e8;
|
| 338 |
+
border-radius: 8px;
|
| 339 |
+
padding: 15px;
|
| 340 |
+
margin: 10px 0;
|
| 341 |
+
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
| 342 |
+
}
|
| 343 |
+
|
| 344 |
+
.card-header {
|
| 345 |
+
display: flex;
|
| 346 |
+
justify-content: space-between;
|
| 347 |
+
align-items: center;
|
| 348 |
+
margin-bottom: 10px;
|
| 349 |
+
flex-wrap: wrap;
|
| 350 |
+
}
|
| 351 |
+
|
| 352 |
+
.card-title {
|
| 353 |
+
font-weight: bold;
|
| 354 |
+
color: #333;
|
| 355 |
+
font-size: 14px;
|
| 356 |
+
word-break: break-all;
|
| 357 |
+
}
|
| 358 |
+
|
| 359 |
+
.card-actions {
|
| 360 |
+
display: flex;
|
| 361 |
+
gap: 5px;
|
| 362 |
+
flex-wrap: wrap;
|
| 363 |
+
margin-top: 10px;
|
| 364 |
+
}
|
| 365 |
+
|
| 366 |
+
/* 移动端优化的统计样式 */
|
| 367 |
+
.stats-container {
|
| 368 |
+
display: grid;
|
| 369 |
+
grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
|
| 370 |
+
gap: 10px;
|
| 371 |
+
margin-bottom: 15px;
|
| 372 |
+
}
|
| 373 |
+
|
| 374 |
+
.stat-item {
|
| 375 |
+
background: white;
|
| 376 |
+
border: 1px solid #dee2e6;
|
| 377 |
+
border-radius: 8px;
|
| 378 |
+
padding: 12px;
|
| 379 |
+
text-align: center;
|
| 380 |
+
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
| 381 |
+
}
|
| 382 |
+
|
| 383 |
+
.stat-number {
|
| 384 |
+
font-size: 20px;
|
| 385 |
+
font-weight: bold;
|
| 386 |
+
color: #333;
|
| 387 |
+
display: block;
|
| 388 |
+
}
|
| 389 |
+
|
| 390 |
+
.stat-label {
|
| 391 |
+
font-size: 12px;
|
| 392 |
+
color: #666;
|
| 393 |
+
margin-top: 4px;
|
| 394 |
+
}
|
| 395 |
+
|
| 396 |
+
/* 移动端优化的进度条 */
|
| 397 |
+
.progress-bar {
|
| 398 |
+
width: 100%;
|
| 399 |
+
height: 8px;
|
| 400 |
+
background-color: #e9ecef;
|
| 401 |
+
border-radius: 4px;
|
| 402 |
+
overflow: hidden;
|
| 403 |
+
margin: 8px 0;
|
| 404 |
+
}
|
| 405 |
+
|
| 406 |
+
.progress-fill {
|
| 407 |
+
height: 100%;
|
| 408 |
+
background-color: #28a745;
|
| 409 |
+
transition: width 0.3s ease;
|
| 410 |
+
}
|
| 411 |
+
|
| 412 |
+
/* 移动端优化的模态框 */
|
| 413 |
+
.modal {
|
| 414 |
+
display: none;
|
| 415 |
+
position: fixed;
|
| 416 |
+
z-index: 1000;
|
| 417 |
+
left: 0;
|
| 418 |
+
top: 0;
|
| 419 |
+
width: 100%;
|
| 420 |
+
height: 100%;
|
| 421 |
+
background-color: rgba(0, 0, 0, 0.5);
|
| 422 |
+
}
|
| 423 |
+
|
| 424 |
+
.modal-content {
|
| 425 |
+
background-color: white;
|
| 426 |
+
margin: 5% auto;
|
| 427 |
+
padding: 20px;
|
| 428 |
+
border-radius: 8px;
|
| 429 |
+
width: 95%;
|
| 430 |
+
max-width: 400px;
|
| 431 |
+
max-height: 90vh;
|
| 432 |
+
overflow-y: auto;
|
| 433 |
+
}
|
| 434 |
+
|
| 435 |
+
.modal-header {
|
| 436 |
+
display: flex;
|
| 437 |
+
justify-content: space-between;
|
| 438 |
+
align-items: center;
|
| 439 |
+
margin-bottom: 15px;
|
| 440 |
+
padding-bottom: 10px;
|
| 441 |
+
border-bottom: 1px solid #dee2e6;
|
| 442 |
+
}
|
| 443 |
+
|
| 444 |
+
.modal-close {
|
| 445 |
+
background: none;
|
| 446 |
+
border: none;
|
| 447 |
+
font-size: 24px;
|
| 448 |
+
cursor: pointer;
|
| 449 |
+
color: #999;
|
| 450 |
+
}
|
| 451 |
+
|
| 452 |
+
/* 响应式优化 */
|
| 453 |
+
@media (max-width: 768px) {
|
| 454 |
+
.container {
|
| 455 |
+
padding: 5px;
|
| 456 |
+
}
|
| 457 |
+
|
| 458 |
+
h1 {
|
| 459 |
+
font-size: 18px;
|
| 460 |
+
}
|
| 461 |
+
|
| 462 |
+
.tabs {
|
| 463 |
+
font-size: 13px;
|
| 464 |
+
}
|
| 465 |
+
|
| 466 |
+
.tab {
|
| 467 |
+
padding: 10px 12px;
|
| 468 |
+
min-width: 70px;
|
| 469 |
+
}
|
| 470 |
+
|
| 471 |
+
.btn {
|
| 472 |
+
padding: 12px 16px;
|
| 473 |
+
font-size: 15px;
|
| 474 |
+
}
|
| 475 |
+
|
| 476 |
+
.modal-content {
|
| 477 |
+
width: 98%;
|
| 478 |
+
margin: 2% auto;
|
| 479 |
+
}
|
| 480 |
+
}
|
| 481 |
+
|
| 482 |
+
@media (max-width: 480px) {
|
| 483 |
+
.stats-container {
|
| 484 |
+
grid-template-columns: repeat(2, 1fr);
|
| 485 |
+
}
|
| 486 |
+
|
| 487 |
+
.card-header {
|
| 488 |
+
flex-direction: column;
|
| 489 |
+
align-items: flex-start;
|
| 490 |
+
gap: 8px;
|
| 491 |
+
}
|
| 492 |
+
|
| 493 |
+
.card-actions {
|
| 494 |
+
width: 100%;
|
| 495 |
+
justify-content: space-between;
|
| 496 |
+
}
|
| 497 |
+
|
| 498 |
+
.btn-small {
|
| 499 |
+
flex: 1;
|
| 500 |
+
margin: 1px;
|
| 501 |
+
font-size: 12px;
|
| 502 |
+
padding: 6px 8px;
|
| 503 |
+
}
|
| 504 |
+
}
|
| 505 |
+
|
| 506 |
+
.log-entry {
|
| 507 |
+
margin-bottom: 2px;
|
| 508 |
+
padding: 2px 0;
|
| 509 |
+
border-left: 3px solid transparent;
|
| 510 |
+
padding-left: 8px;
|
| 511 |
+
}
|
| 512 |
+
|
| 513 |
+
.log-debug {
|
| 514 |
+
color: #888;
|
| 515 |
+
border-left-color: #888;
|
| 516 |
+
}
|
| 517 |
+
|
| 518 |
+
.log-info {
|
| 519 |
+
color: #d4d4d4;
|
| 520 |
+
border-left-color: #007acc;
|
| 521 |
+
}
|
| 522 |
+
|
| 523 |
+
.log-warning {
|
| 524 |
+
color: #ffcc02;
|
| 525 |
+
border-left-color: #ffcc02;
|
| 526 |
+
}
|
| 527 |
+
|
| 528 |
+
.log-error {
|
| 529 |
+
color: #f48771;
|
| 530 |
+
border-left-color: #f48771;
|
| 531 |
+
}
|
| 532 |
+
|
| 533 |
+
.log-critical {
|
| 534 |
+
color: #ff6b6b;
|
| 535 |
+
background-color: rgba(255, 107, 107, 0.1);
|
| 536 |
+
border-left-color: #ff6b6b;
|
| 537 |
+
}
|
| 538 |
+
|
| 539 |
+
.log-timestamp {
|
| 540 |
+
color: #569cd6;
|
| 541 |
+
margin-right: 8px;
|
| 542 |
+
}
|
| 543 |
+
|
| 544 |
+
.log-level {
|
| 545 |
+
font-weight: bold;
|
| 546 |
+
margin-right: 8px;
|
| 547 |
+
min-width: 60px;
|
| 548 |
+
display: inline-block;
|
| 549 |
+
}
|
| 550 |
+
|
| 551 |
+
.log-message {
|
| 552 |
+
word-break: break-all;
|
| 553 |
+
}
|
| 554 |
+
|
| 555 |
+
/* 文件管理卡片样式 */
|
| 556 |
+
.cred-card {
|
| 557 |
+
background-color: #f8f9fa;
|
| 558 |
+
border: 1px solid #e1e4e8;
|
| 559 |
+
border-radius: 8px;
|
| 560 |
+
padding: 15px;
|
| 561 |
+
margin: 10px 0;
|
| 562 |
+
position: relative;
|
| 563 |
+
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
| 564 |
+
}
|
| 565 |
+
|
| 566 |
+
.cred-card.disabled {
|
| 567 |
+
background-color: #f5f5f5;
|
| 568 |
+
border-color: #ccc;
|
| 569 |
+
opacity: 0.7;
|
| 570 |
+
}
|
| 571 |
+
|
| 572 |
+
.cred-header {
|
| 573 |
+
display: flex;
|
| 574 |
+
justify-content: space-between;
|
| 575 |
+
align-items: flex-start;
|
| 576 |
+
margin-bottom: 10px;
|
| 577 |
+
gap: 10px;
|
| 578 |
+
flex-wrap: wrap;
|
| 579 |
+
}
|
| 580 |
+
|
| 581 |
+
.cred-filename {
|
| 582 |
+
font-family: monospace;
|
| 583 |
+
font-weight: bold;
|
| 584 |
+
color: #333;
|
| 585 |
+
font-size: 13px;
|
| 586 |
+
word-break: break-all;
|
| 587 |
+
flex: 1;
|
| 588 |
+
min-width: 0;
|
| 589 |
+
}
|
| 590 |
+
|
| 591 |
+
.cred-status {
|
| 592 |
+
display: flex;
|
| 593 |
+
gap: 5px;
|
| 594 |
+
flex-wrap: wrap;
|
| 595 |
+
}
|
| 596 |
+
|
| 597 |
+
.status-badge {
|
| 598 |
+
padding: 2px 8px;
|
| 599 |
+
border-radius: 12px;
|
| 600 |
+
font-size: 11px;
|
| 601 |
+
color: white;
|
| 602 |
+
white-space: nowrap;
|
| 603 |
+
}
|
| 604 |
+
|
| 605 |
+
.status-badge.enabled {
|
| 606 |
+
background-color: #28a745;
|
| 607 |
+
}
|
| 608 |
+
|
| 609 |
+
.status-badge.disabled {
|
| 610 |
+
background-color: #6c757d;
|
| 611 |
+
}
|
| 612 |
+
|
| 613 |
+
.error-codes {
|
| 614 |
+
background-color: #f8d7da;
|
| 615 |
+
color: #721c24;
|
| 616 |
+
padding: 2px 8px;
|
| 617 |
+
border-radius: 12px;
|
| 618 |
+
font-size: 11px;
|
| 619 |
+
}
|
| 620 |
+
|
| 621 |
+
.cooldown-badge {
|
| 622 |
+
background-color: #ffc107;
|
| 623 |
+
color: #856404;
|
| 624 |
+
padding: 2px 8px;
|
| 625 |
+
border-radius: 12px;
|
| 626 |
+
font-size: 11px;
|
| 627 |
+
font-weight: bold;
|
| 628 |
+
white-space: nowrap;
|
| 629 |
+
}
|
| 630 |
+
|
| 631 |
+
.cooldown-badge.ready {
|
| 632 |
+
background-color: #28a745;
|
| 633 |
+
color: white;
|
| 634 |
+
}
|
| 635 |
+
|
| 636 |
+
.model-cooldown-details {
|
| 637 |
+
background-color: #e3f2fd;
|
| 638 |
+
border: 1px solid #90caf9;
|
| 639 |
+
border-radius: 4px;
|
| 640 |
+
padding: 8px;
|
| 641 |
+
margin-top: 8px;
|
| 642 |
+
font-size: 11px;
|
| 643 |
+
color: #1976d2;
|
| 644 |
+
}
|
| 645 |
+
|
| 646 |
+
.model-cooldown-item {
|
| 647 |
+
display: inline-block;
|
| 648 |
+
background-color: #64b5f6;
|
| 649 |
+
color: white;
|
| 650 |
+
padding: 2px 6px;
|
| 651 |
+
border-radius: 10px;
|
| 652 |
+
margin: 2px;
|
| 653 |
+
font-size: 10px;
|
| 654 |
+
}
|
| 655 |
+
|
| 656 |
+
.cred-actions {
|
| 657 |
+
display: flex;
|
| 658 |
+
gap: 5px;
|
| 659 |
+
flex-wrap: wrap;
|
| 660 |
+
margin-top: 10px;
|
| 661 |
+
}
|
| 662 |
+
|
| 663 |
+
.cred-btn {
|
| 664 |
+
padding: 6px 12px;
|
| 665 |
+
border: none;
|
| 666 |
+
border-radius: 4px;
|
| 667 |
+
font-size: 12px;
|
| 668 |
+
cursor: pointer;
|
| 669 |
+
transition: background-color 0.2s;
|
| 670 |
+
white-space: nowrap;
|
| 671 |
+
}
|
| 672 |
+
|
| 673 |
+
.cred-btn.enable {
|
| 674 |
+
background-color: #28a745;
|
| 675 |
+
color: white;
|
| 676 |
+
}
|
| 677 |
+
|
| 678 |
+
.cred-btn.disable {
|
| 679 |
+
background-color: #6c757d;
|
| 680 |
+
color: white;
|
| 681 |
+
}
|
| 682 |
+
|
| 683 |
+
.cred-btn.delete {
|
| 684 |
+
background-color: #dc3545;
|
| 685 |
+
color: white;
|
| 686 |
+
}
|
| 687 |
+
|
| 688 |
+
.cred-btn.download {
|
| 689 |
+
background-color: #007bff;
|
| 690 |
+
color: white;
|
| 691 |
+
}
|
| 692 |
+
|
| 693 |
+
.cred-btn.view {
|
| 694 |
+
background-color: #17a2b8;
|
| 695 |
+
color: white;
|
| 696 |
+
}
|
| 697 |
+
|
| 698 |
+
.cred-btn.email {
|
| 699 |
+
background-color: #17a2b8;
|
| 700 |
+
color: white;
|
| 701 |
+
}
|
| 702 |
+
|
| 703 |
+
.cred-details {
|
| 704 |
+
margin-top: 10px;
|
| 705 |
+
display: none;
|
| 706 |
+
}
|
| 707 |
+
|
| 708 |
+
.cred-details.show {
|
| 709 |
+
display: block;
|
| 710 |
+
}
|
| 711 |
+
|
| 712 |
+
.cred-content {
|
| 713 |
+
background-color: #f0f8ff;
|
| 714 |
+
border: 1px solid #b0d4ff;
|
| 715 |
+
border-radius: 4px;
|
| 716 |
+
padding: 10px;
|
| 717 |
+
font-family: monospace;
|
| 718 |
+
font-size: 11px;
|
| 719 |
+
white-space: pre-wrap;
|
| 720 |
+
word-break: break-all;
|
| 721 |
+
max-height: 200px;
|
| 722 |
+
overflow-y: auto;
|
| 723 |
+
}
|
| 724 |
+
|
| 725 |
+
/* 额度信息显示样式 */
|
| 726 |
+
.cred-quota-details {
|
| 727 |
+
margin-top: 10px;
|
| 728 |
+
animation: slideDown 0.3s ease-out;
|
| 729 |
+
}
|
| 730 |
+
|
| 731 |
+
@keyframes slideDown {
|
| 732 |
+
from {
|
| 733 |
+
opacity: 0;
|
| 734 |
+
transform: translateY(-10px);
|
| 735 |
+
}
|
| 736 |
+
|
| 737 |
+
to {
|
| 738 |
+
opacity: 1;
|
| 739 |
+
transform: translateY(0);
|
| 740 |
+
}
|
| 741 |
+
}
|
| 742 |
+
|
| 743 |
+
.cred-quota-content {
|
| 744 |
+
background: linear-gradient(to bottom, #ffffff, #f8f9fa);
|
| 745 |
+
border: 2px solid #17a2b8;
|
| 746 |
+
border-radius: 8px;
|
| 747 |
+
padding: 10px;
|
| 748 |
+
box-shadow: 0 2px 8px rgba(23, 162, 184, 0.15);
|
| 749 |
+
}
|
| 750 |
+
|
| 751 |
+
/* 批量操作按钮禁用状态 */
|
| 752 |
+
.btn-small:disabled {
|
| 753 |
+
background-color: #e9ecef !important;
|
| 754 |
+
color: #6c757d !important;
|
| 755 |
+
cursor: not-allowed;
|
| 756 |
+
opacity: 0.6;
|
| 757 |
+
}
|
| 758 |
+
|
| 759 |
+
/* 批量操作控件优化 */
|
| 760 |
+
.batch-controls-grid {
|
| 761 |
+
display: grid;
|
| 762 |
+
grid-template-columns: repeat(2, 1fr);
|
| 763 |
+
gap: 8px;
|
| 764 |
+
}
|
| 765 |
+
|
| 766 |
+
@media (max-width: 400px) {
|
| 767 |
+
.batch-controls-grid {
|
| 768 |
+
grid-template-columns: 1fr;
|
| 769 |
+
}
|
| 770 |
+
}
|
| 771 |
+
|
| 772 |
+
/* 文件卡片复选框区域优化 */
|
| 773 |
+
.file-selection-area {
|
| 774 |
+
display: flex;
|
| 775 |
+
align-items: flex-start;
|
| 776 |
+
gap: 10px;
|
| 777 |
+
}
|
| 778 |
+
|
| 779 |
+
@media (max-width: 400px) {
|
| 780 |
+
.file-selection-area {
|
| 781 |
+
gap: 8px;
|
| 782 |
+
}
|
| 783 |
+
|
| 784 |
+
.file-checkbox {
|
| 785 |
+
transform: scale(1.1) !important;
|
| 786 |
+
}
|
| 787 |
+
}
|
| 788 |
+
</style>
|
| 789 |
+
</head>
|
| 790 |
+
|
| 791 |
+
<body>
|
| 792 |
+
<div class="container">
|
| 793 |
+
|
| 794 |
+
<!-- 登录界面 -->
|
| 795 |
+
<div id="loginSection" class="login-form">
|
| 796 |
+
<h1>GCLI2API 移动端控制面板</h1>
|
| 797 |
+
<p>请输入访问密码:</p>
|
| 798 |
+
<input type="password" id="loginPassword" placeholder="输入密码" onkeypress="handlePasswordEnter(event)" />
|
| 799 |
+
<br><br>
|
| 800 |
+
<button class="btn" onclick="login()">登录</button>
|
| 801 |
+
</div>
|
| 802 |
+
|
| 803 |
+
<!-- 主界面 -->
|
| 804 |
+
<div id="mainSection" class="hidden">
|
| 805 |
+
<div
|
| 806 |
+
style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 15px; flex-wrap: wrap; gap: 10px;">
|
| 807 |
+
<div style="display: flex; flex-direction: column; gap: 6px; flex: 1;">
|
| 808 |
+
<h1 style="margin: 0; font-size: 20px;">GCLI2API 移动端控制面板</h1>
|
| 809 |
+
<div style="display: flex; align-items: center; gap: 8px; flex-wrap: wrap;">
|
| 810 |
+
<span id="versionInfo" style="font-size: 11px; color: #666;">
|
| 811 |
+
<span id="versionText">加载中...</span>
|
| 812 |
+
</span>
|
| 813 |
+
<button onclick="checkForUpdates()" id="checkUpdateBtn"
|
| 814 |
+
style="padding: 2px 8px; background-color: #17a2b8; color: white; border: none; border-radius: 3px; font-size: 10px; cursor: pointer; white-space: nowrap;">
|
| 815 |
+
检查更新
|
| 816 |
+
</button>
|
| 817 |
+
</div>
|
| 818 |
+
</div>
|
| 819 |
+
<button onclick="logout()"
|
| 820 |
+
style="padding: 6px 15px; background-color: #dc3545; color: white; border: none; border-radius: 4px; font-size: 12px; cursor: pointer; white-space: nowrap;">
|
| 821 |
+
退出登录
|
| 822 |
+
</button>
|
| 823 |
+
</div>
|
| 824 |
+
|
| 825 |
+
<!-- 移动端优化的标签页 -->
|
| 826 |
+
<!-- 移动端优化的标签页 -->
|
| 827 |
+
<div class="tabs">
|
| 828 |
+
<div class="tab-slider"></div>
|
| 829 |
+
<button class="tab active" onclick="switchTab('oauth')">OAuth认证</button>
|
| 830 |
+
<button class="tab" onclick="switchTab('antigravity')">Antigravity认证</button>
|
| 831 |
+
<button class="tab" onclick="switchTab('upload')">批量上传</button>
|
| 832 |
+
<button class="tab" onclick="switchTab('manage')">GCLI凭证</button>
|
| 833 |
+
<button class="tab" onclick="switchTab('antigravity-manage')">AG凭证</button>
|
| 834 |
+
<button class="tab" onclick="switchTab('config')">配置管理</button>
|
| 835 |
+
<button class="tab" onclick="switchTab('logs')">实时日志</button>
|
| 836 |
+
<button class="tab" onclick="switchTab('about')">项目信息</button>
|
| 837 |
+
</div>
|
| 838 |
+
|
| 839 |
+
<!-- OAuth认证标签页 -->
|
| 840 |
+
<div id="oauthTab" class="tab-content active">
|
| 841 |
+
<!-- API 自动启用说明 -->
|
| 842 |
+
<div class="status success" style="margin-bottom: 20px;">
|
| 843 |
+
<strong>✨ 自动化优化:</strong> 系统现在会在认证成功后自动为您的项目启用必需的API服务
|
| 844 |
+
<ul style="margin: 10px 0; padding-left: 20px; font-size: 13px;">
|
| 845 |
+
<li><strong>Gemini Cloud Assist API</strong></li>
|
| 846 |
+
<li><strong>Gemini for Google Cloud API</strong></li>
|
| 847 |
+
</ul>
|
| 848 |
+
<p style="margin: 10px 0; color: #155724; font-size: 13px;">
|
| 849 |
+
<strong>说明:</strong>无需手动启用API,系统会自动处理这些配置步骤,让认证流程更加顺畅。
|
| 850 |
+
</p>
|
| 851 |
+
</div>
|
| 852 |
+
|
| 853 |
+
<!-- 折叠式 Project ID 输入框 -->
|
| 854 |
+
<div class="form-group">
|
| 855 |
+
<div style="cursor: pointer; user-select: none; padding: 12px; border: 2px solid #ddd; border-radius: 8px; background: #f8f8f8; display: flex; justify-content: space-between; align-items: center;"
|
| 856 |
+
onclick="toggleProjectIdSection()">
|
| 857 |
+
<span style="font-weight: bold; color: #555; font-size: 14px;">📁 高级选项:Google Cloud Project ID
|
| 858 |
+
(不用管,直接点击获取链接即可)</span>
|
| 859 |
+
<span id="projectIdToggleIcon"
|
| 860 |
+
style="font-size: 14px; color: #666; transition: transform 0.3s ease;">▶</span>
|
| 861 |
+
</div>
|
| 862 |
+
<div id="projectIdSection"
|
| 863 |
+
style="display: none; margin-top: 10px; padding: 12px; border: 2px solid #ddd; border-top: none; border-radius: 0 0 8px 8px; background: #ffffff;">
|
| 864 |
+
<label for="projectId">Google Cloud Project ID (可选):</label>
|
| 865 |
+
<input type="text" id="projectId" placeholder="留空将尝试自动检测,或手动输入项目ID" />
|
| 866 |
+
<small style="color: #666; font-size: 12px; margin-top: 5px; display: block;">
|
| 867 |
+
💡 提示:如果你不懂这是什么,可以留空此字段让系统自动检测项目ID
|
| 868 |
+
</small>
|
| 869 |
+
</div>
|
| 870 |
+
</div>
|
| 871 |
+
|
| 872 |
+
<button class="btn" id="getAuthBtn" onclick="startAuth()">获取认证链接</button>
|
| 873 |
+
|
| 874 |
+
<div id="authUrlSection" class="hidden">
|
| 875 |
+
<h4>认证链接:</h4>
|
| 876 |
+
<div
|
| 877 |
+
style="background-color: #f8f9fa; border: 1px solid #e1e4e8; border-radius: 8px; padding: 12px; margin: 15px 0; word-break: break-all;">
|
| 878 |
+
<a id="authUrl" href="#" target="_blank"
|
| 879 |
+
style="color: #4285f4; text-decoration: none;">点击此链接进行认证</a>
|
| 880 |
+
</div>
|
| 881 |
+
<div class="status info">
|
| 882 |
+
<strong>重要说明:</strong>
|
| 883 |
+
<ol style="margin: 10px 0; padding-left: 20px; font-size: 13px;">
|
| 884 |
+
<li>点击上方认证链接,会在新窗口中打开Google OAuth页面</li>
|
| 885 |
+
<li>完成Google账号登录和授权</li>
|
| 886 |
+
<li>授权成功后会跳转到localhost:11451显示成功页面</li>
|
| 887 |
+
<li>关闭OAuth窗口,返回本页面</li>
|
| 888 |
+
<li>点击下方"获取认证文件"按钮完成流程</li>
|
| 889 |
+
</ol>
|
| 890 |
+
</div>
|
| 891 |
+
|
| 892 |
+
<!-- 快捷回调URL输入选项 -->
|
| 893 |
+
<div class="form-group"
|
| 894 |
+
style="margin: 20px 0; padding: 15px; border: 2px solid #e8f4fd; border-radius: 8px; background: #f8fcff;">
|
| 895 |
+
<div style="cursor: pointer; user-select: none; display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;"
|
| 896 |
+
onclick="toggleCallbackUrlSection()">
|
| 897 |
+
<span style="font-weight: bold; color: #0066cc;">🚀 无法回源?试试快捷方式</span>
|
| 898 |
+
<span id="callbackUrlToggleIcon"
|
| 899 |
+
style="font-size: 14px; color: #666; transition: transform 0.3s ease;">▼</span>
|
| 900 |
+
</div>
|
| 901 |
+
<div id="callbackUrlSection" style="display: none;">
|
| 902 |
+
<div
|
| 903 |
+
style="background: #fff3cd; border: 1px solid #ffeaa7; border-radius: 6px; padding: 12px; margin-bottom: 12px;">
|
| 904 |
+
<div style="color: #856404; font-size: 14px; font-weight: bold; margin-bottom: 6px;">📚
|
| 905 |
+
适用场景:</div>
|
| 906 |
+
<ul
|
| 907 |
+
style="color: #856404; font-size: 13px; margin: 0; padding-left: 18px; line-height: 1.5;">
|
| 908 |
+
<li>云服务器、VPS等非本地环境</li>
|
| 909 |
+
<li>防火墙阻止了11451端口访问</li>
|
| 910 |
+
<li>网络环境无法正常回源到localhost</li>
|
| 911 |
+
<li>Docker容器内运行,端口映射问题</li>
|
| 912 |
+
</ul>
|
| 913 |
+
</div>
|
| 914 |
+
<div style="color: #666; font-size: 13px; margin-bottom: 12px; line-height: 1.6;">
|
| 915 |
+
<strong style="color: #0066cc;">🔍 什么是回调URL?</strong><br>
|
| 916 |
+
完成Google OAuth授权后,浏览器地址栏显示的完整URL,通常看起来像这样:<br>
|
| 917 |
+
<code
|
| 918 |
+
style="background: #f1f3f4; padding: 2px 6px; border-radius: 3px; font-size: 12px; word-break: break-all;">
|
| 919 |
+
http://localhost:11451/?state=abc123...&code=4/0AVMBsJ...&scope=email%20profile...
|
| 920 |
+
</code>
|
| 921 |
+
</div>
|
| 922 |
+
<div
|
| 923 |
+
style="background: #e7f3ff; border: 1px solid #b3d9ff; border-radius: 6px; padding: 10px; margin-bottom: 12px;">
|
| 924 |
+
<div style="color: #0066cc; font-size: 13px; font-weight: bold; margin-bottom: 4px;">📋
|
| 925 |
+
使用步骤:</div>
|
| 926 |
+
<ol
|
| 927 |
+
style="color: #0066cc; font-size: 12px; margin: 0; padding-left: 18px; line-height: 1.4;">
|
| 928 |
+
<li>点击上方认证链接,完成Google授权</li>
|
| 929 |
+
<li>授权成功后,复制浏览器地址栏的<strong>完整URL</strong></li>
|
| 930 |
+
<li>粘贴到下方输入框,点击获取凭证即可</li>
|
| 931 |
+
</ol>
|
| 932 |
+
</div>
|
| 933 |
+
<div class="input-group">
|
| 934 |
+
<input type="url" id="callbackUrlInput"
|
| 935 |
+
placeholder="粘贴完整的回调URL,例如:http://localhost:11451/?state=xxx&code=xxx&scope=xxx..."
|
| 936 |
+
style="width: 100%; padding: 10px; border: 2px solid #ddd; border-radius: 4px; font-size: 13px;">
|
| 937 |
+
</div>
|
| 938 |
+
<button class="btn" style="margin-top: 10px; background: #28a745; border-color: #28a745;"
|
| 939 |
+
onclick="processCallbackUrl()">
|
| 940 |
+
从回调URL获取凭证
|
| 941 |
+
</button>
|
| 942 |
+
</div>
|
| 943 |
+
</div>
|
| 944 |
+
|
| 945 |
+
<button class="btn" id="getCredsBtn" onclick="getCredentials()">获取认证文件</button>
|
| 946 |
+
</div>
|
| 947 |
+
|
| 948 |
+
<div id="credentialsSection" class="hidden">
|
| 949 |
+
<h4>认证文件内容:</h4>
|
| 950 |
+
<div style="background-color: #f0f8ff; border: 1px solid #b0d4ff; border-radius: 8px; padding: 12px; font-family: monospace; font-size: 11px; white-space: pre-wrap; word-break: break-all; max-height: 300px; overflow-y: auto;"
|
| 951 |
+
id="credentialsContent"></div>
|
| 952 |
+
</div>
|
| 953 |
+
</div>
|
| 954 |
+
|
| 955 |
+
<!-- Antigravity 认证标签页 -->
|
| 956 |
+
<div id="antigravityTab" class="tab-content">
|
| 957 |
+
<div class="status info" style="margin-bottom: 20px;">
|
| 958 |
+
<strong>🚀 Antigravity 认证模式</strong>
|
| 959 |
+
<p style="margin: 10px 0; font-size: 13px;">
|
| 960 |
+
获取谷歌Antigravity 凭证
|
| 961 |
+
</p>
|
| 962 |
+
</div>
|
| 963 |
+
|
| 964 |
+
<button class="btn" id="getAntigravityAuthBtn">获取 Antigravity 认证链接</button>
|
| 965 |
+
|
| 966 |
+
<div id="antigravityAuthUrlSection" class="hidden">
|
| 967 |
+
<h4>Antigravity 认证链接:</h4>
|
| 968 |
+
<div
|
| 969 |
+
style="background-color: #f8f9fa; border: 1px solid #e1e4e8; border-radius: 8px; padding: 12px; margin: 15px 0; word-break: break-all;">
|
| 970 |
+
<a id="antigravityAuthUrl" href="#" target="_blank"
|
| 971 |
+
style="color: #4285f4; text-decoration: none;">点击此链接进行认证</a>
|
| 972 |
+
</div>
|
| 973 |
+
<div class="status info">
|
| 974 |
+
<strong>使用说明:</strong>
|
| 975 |
+
<ol style="margin: 10px 0; padding-left: 20px; font-size: 13px;">
|
| 976 |
+
<li>点击上方认证链接,在新窗口中完成 Google 授权</li>
|
| 977 |
+
<li>授权成功后会跳转到 localhost 显示成功页面</li>
|
| 978 |
+
<li>关闭 OAuth 窗口,返回本页面</li>
|
| 979 |
+
<li>点击下方"获取凭证"按钮完成流程</li>
|
| 980 |
+
</ol>
|
| 981 |
+
</div>
|
| 982 |
+
|
| 983 |
+
<!-- 快捷回调URL输入选项 -->
|
| 984 |
+
<div class="form-group"
|
| 985 |
+
style="margin: 15px 0; padding: 12px; border: 2px solid #e8f4fd; border-radius: 8px; background: #f8fcff;">
|
| 986 |
+
<div style="cursor: pointer; user-select: none; display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;"
|
| 987 |
+
onclick="toggleAntigravityCallbackUrlSection()">
|
| 988 |
+
<span style="font-weight: bold; color: #0066cc; font-size: 13px;">🚀 无法回源?试试快捷方式</span>
|
| 989 |
+
<span id="antigravityCallbackUrlToggleIcon"
|
| 990 |
+
style="font-size: 14px; color: #666; transition: transform 0.3s ease;">▼</span>
|
| 991 |
+
</div>
|
| 992 |
+
<div id="antigravityCallbackUrlSection" style="display: none;">
|
| 993 |
+
<div
|
| 994 |
+
style="background: #fff3cd; border: 1px solid #ffeaa7; border-radius: 6px; padding: 10px; margin-bottom: 10px;">
|
| 995 |
+
<div style="color: #856404; font-size: 12px; font-weight: bold; margin-bottom: 4px;">📚
|
| 996 |
+
适用场景:</div>
|
| 997 |
+
<ul
|
| 998 |
+
style="color: #856404; font-size: 11px; margin: 0; padding-left: 16px; line-height: 1.4;">
|
| 999 |
+
<li>云服务器、VPS等非本地环境</li>
|
| 1000 |
+
<li>防火墙阻止了11451端口访问</li>
|
| 1001 |
+
<li>网络环境无法正常回源到localhost</li>
|
| 1002 |
+
<li>Docker容器内运行,端口映射问题</li>
|
| 1003 |
+
</ul>
|
| 1004 |
+
</div>
|
| 1005 |
+
<div style="color: #666; font-size: 11px; margin-bottom: 10px; line-height: 1.5;">
|
| 1006 |
+
<strong style="color: #0066cc;">🔍 什么是回调URL?</strong><br>
|
| 1007 |
+
完成Google OAuth授权后,浏览器地址栏显示的完整URL,通常看起来像这样:<br>
|
| 1008 |
+
<code
|
| 1009 |
+
style="background: #f1f3f4; padding: 2px 4px; border-radius: 3px; font-size: 10px; word-break: break-all;">
|
| 1010 |
+
http://localhost:11451/?state=abc123...&code=4/0AVMBsJ...&scope=email%20profile...
|
| 1011 |
+
</code>
|
| 1012 |
+
</div>
|
| 1013 |
+
<div
|
| 1014 |
+
style="background: #e7f3ff; border: 1px solid #b3d9ff; border-radius: 6px; padding: 8px; margin-bottom: 10px;">
|
| 1015 |
+
<div style="color: #0066cc; font-size: 11px; font-weight: bold; margin-bottom: 3px;">📋
|
| 1016 |
+
使用步骤:</div>
|
| 1017 |
+
<ol
|
| 1018 |
+
style="color: #0066cc; font-size: 10px; margin: 0; padding-left: 16px; line-height: 1.3;">
|
| 1019 |
+
<li>点击上方认证链接,完成Google授权</li>
|
| 1020 |
+
<li>授权成功后,复制浏览器地址栏的<strong>完整URL</strong></li>
|
| 1021 |
+
<li>粘贴到下方输入框,点击获取凭证即可</li>
|
| 1022 |
+
</ol>
|
| 1023 |
+
</div>
|
| 1024 |
+
<div class="input-group">
|
| 1025 |
+
<input type="url" id="antigravityCallbackUrlInput"
|
| 1026 |
+
placeholder="粘贴完整的回调URL,例如:http://localhost:11451/?state=xxx&code=xxx&scope=xxx..."
|
| 1027 |
+
style="width: 100%; padding: 8px; border: 2px solid #ddd; border-radius: 4px; font-size: 12px;">
|
| 1028 |
+
</div>
|
| 1029 |
+
<button class="btn"
|
| 1030 |
+
style="margin-top: 8px; background: #28a745; border-color: #28a745; font-size: 13px;"
|
| 1031 |
+
onclick="processAntigravityCallbackUrl()">
|
| 1032 |
+
从回调URL获取凭证
|
| 1033 |
+
</button>
|
| 1034 |
+
</div>
|
| 1035 |
+
</div>
|
| 1036 |
+
|
| 1037 |
+
<button class="btn" id="getAntigravityCredsBtn" onclick="getAntigravityCredentials()">获取 Antigravity
|
| 1038 |
+
凭证</button>
|
| 1039 |
+
|
| 1040 |
+
<div id="antigravityCredsSection" class="hidden">
|
| 1041 |
+
<h4>Antigravity 凭证内容:</h4>
|
| 1042 |
+
<div
|
| 1043 |
+
style="background-color: #f0f8ff; border: 1px solid #b0d4ff; border-radius: 8px; padding: 12px; font-family: monospace; font-size: 11px; white-space: pre-wrap; word-break: break-all; max-height: 300px; overflow-y: auto;">
|
| 1044 |
+
<pre id="antigravityCredsContent"></pre>
|
| 1045 |
+
</div>
|
| 1046 |
+
<button class="btn" onclick="downloadAntigravityCredentials()"
|
| 1047 |
+
style="margin-top: 10px;">下载凭证文件</button>
|
| 1048 |
+
</div>
|
| 1049 |
+
</div>
|
| 1050 |
+
</div>
|
| 1051 |
+
|
| 1052 |
+
<!-- 批量上传标签页 -->
|
| 1053 |
+
<div id="uploadTab" class="tab-content">
|
| 1054 |
+
<h3>批量上传认证文件</h3>
|
| 1055 |
+
<p>支持批量上传 GCLI 和 Antigravity 认证文件</p>
|
| 1056 |
+
|
| 1057 |
+
<!-- GCLI凭证上传区域 -->
|
| 1058 |
+
<div
|
| 1059 |
+
style="margin-bottom: 25px; padding: 15px; border: 2px solid #007bff; border-radius: 8px; background: #f8f9fa;">
|
| 1060 |
+
<h4
|
| 1061 |
+
style="margin-top: 0; color: #007bff; border-bottom: 2px solid #007bff; padding-bottom: 8px; font-size: 16px;">
|
| 1062 |
+
📤 GCLI 凭证批量上传</h4>
|
| 1063 |
+
|
| 1064 |
+
<div style="border: 2px dashed #007bff; border-radius: 8px; padding: 25px; text-align: center; background-color: #fafafa; margin: 15px 0; cursor: pointer; transition: border-color 0.3s;"
|
| 1065 |
+
id="uploadArea" onclick="document.getElementById('fileInput').click()">
|
| 1066 |
+
<p style="margin: 10px 0; font-size: 16px;">📁 点击选择文件或拖拽文件到此区域</p>
|
| 1067 |
+
<p style="color: #666; font-size: 14px; margin: 5px 0;">支持 .json 和 .zip 格式文件</p>
|
| 1068 |
+
<p style="color: #888; font-size: 12px; margin: 5px 0;">ZIP文件会自动解压提取其中的JSON凭证</p>
|
| 1069 |
+
</div>
|
| 1070 |
+
|
| 1071 |
+
<input type="file" id="fileInput" multiple accept=".json,.zip" style="display: none;"
|
| 1072 |
+
onchange="handleFileSelect(event)" />
|
| 1073 |
+
|
| 1074 |
+
<div id="fileListSection" class="hidden">
|
| 1075 |
+
<h4>选择的文件:</h4>
|
| 1076 |
+
<div id="fileList"></div>
|
| 1077 |
+
<div style="display: flex; gap: 10px; margin-top: 15px;">
|
| 1078 |
+
<button class="btn" onclick="uploadFiles()" style="flex: 1;">上传文件</button>
|
| 1079 |
+
<button class="btn" style="background-color: #6c757d; flex: 1;"
|
| 1080 |
+
onclick="clearFiles()">清空列表</button>
|
| 1081 |
+
</div>
|
| 1082 |
+
</div>
|
| 1083 |
+
|
| 1084 |
+
<div id="uploadProgressSection" class="hidden">
|
| 1085 |
+
<div
|
| 1086 |
+
style="background-color: #f8f9fa; border: 1px solid #e1e4e8; border-radius: 8px; padding: 15px; margin: 20px 0;">
|
| 1087 |
+
<h4>上传进度:</h4>
|
| 1088 |
+
<div class="progress-bar" style="height: 20px;">
|
| 1089 |
+
<div class="progress-fill" id="progressFill" style="width: 0%"></div>
|
| 1090 |
+
</div>
|
| 1091 |
+
<p id="progressText" style="text-align: center; margin: 10px 0;">0%</p>
|
| 1092 |
+
</div>
|
| 1093 |
+
</div>
|
| 1094 |
+
</div>
|
| 1095 |
+
|
| 1096 |
+
<!-- Antigravity凭证上传区域 -->
|
| 1097 |
+
<div style="padding: 15px; border: 2px solid #28a745; border-radius: 8px; background: #f8f9fa;">
|
| 1098 |
+
<h4
|
| 1099 |
+
style="margin-top: 0; color: #28a745; border-bottom: 2px solid #28a745; padding-bottom: 8px; font-size: 16px;">
|
| 1100 |
+
📤 Antigravity 凭证批量上传</h4>
|
| 1101 |
+
|
| 1102 |
+
<div style="border: 2px dashed #28a745; border-radius: 8px; padding: 25px; text-align: center; background-color: #fafafa; margin: 15px 0; cursor: pointer;"
|
| 1103 |
+
onclick="document.getElementById('antigravityFileInput').click()">
|
| 1104 |
+
<p style="color: #28a745; font-size: 16px; font-weight: bold; margin: 8px 0;">📤 批量上传
|
| 1105 |
+
Antigravity 凭证文件</p>
|
| 1106 |
+
<p style="color: #666; font-size: 14px; margin: 4px 0;">支持 .json 和 .zip 格式文件</p>
|
| 1107 |
+
<p style="color: #888; font-size: 12px; margin: 4px 0;">ZIP文件会自动解压提取其中的JSON凭证</p>
|
| 1108 |
+
</div>
|
| 1109 |
+
|
| 1110 |
+
<input type="file" id="antigravityFileInput" multiple accept=".json,.zip" style="display: none;"
|
| 1111 |
+
onchange="handleAntigravityFileSelect(event)" />
|
| 1112 |
+
|
| 1113 |
+
<div id="antigravityFileListSection" class="hidden">
|
| 1114 |
+
<h4>选择的文件:</h4>
|
| 1115 |
+
<div id="antigravityFileList"></div>
|
| 1116 |
+
<div style="display: flex; gap: 10px; margin-top: 15px;">
|
| 1117 |
+
<button class="btn" style="background-color: #28a745; flex: 1;"
|
| 1118 |
+
onclick="uploadAntigravityFiles()">上传文件</button>
|
| 1119 |
+
<button class="btn" style="background-color: #6c757d; flex: 1;"
|
| 1120 |
+
onclick="clearAntigravityFiles()">清空列表</button>
|
| 1121 |
+
</div>
|
| 1122 |
+
</div>
|
| 1123 |
+
|
| 1124 |
+
<div id="antigravityUploadProgressSection" class="hidden">
|
| 1125 |
+
<div
|
| 1126 |
+
style="background-color: #f8f9fa; border: 1px solid #e1e4e8; border-radius: 8px; padding: 15px; margin: 20px 0;">
|
| 1127 |
+
<h4>上传进度:</h4>
|
| 1128 |
+
<div class="progress-bar" style="height: 20px;">
|
| 1129 |
+
<div class="progress-fill" id="antigravityProgressFill" style="width: 0%"></div>
|
| 1130 |
+
</div>
|
| 1131 |
+
<p id="antigravityProgressText" style="text-align: center; margin: 10px 0;">0%</p>
|
| 1132 |
+
</div>
|
| 1133 |
+
</div>
|
| 1134 |
+
</div>
|
| 1135 |
+
</div>
|
| 1136 |
+
|
| 1137 |
+
<div id="manageTab" class="tab-content">
|
| 1138 |
+
<h3>凭证文件管理</h3>
|
| 1139 |
+
<p>管理所有认证文件,查看状态和执行操作</p>
|
| 1140 |
+
|
| 1141 |
+
<!-- 检验功能说明 -->
|
| 1142 |
+
<div class="status info" style="margin-bottom: 15px;">
|
| 1143 |
+
<strong>💡 检验功能说明:</strong>
|
| 1144 |
+
<p style="margin: 8px 0; font-size: 14px;">
|
| 1145 |
+
点击每个凭证的"检验"按钮可以重新获取Project ID。<br>
|
| 1146 |
+
<strong style="color: #0c5460;">✅ 检验成功可以恢复403错误</strong>,让凭证重新正常工作。<br>
|
| 1147 |
+
建议在遇到403错误时使用此功能。
|
| 1148 |
+
</p>
|
| 1149 |
+
</div>
|
| 1150 |
+
|
| 1151 |
+
<!-- 状态统计 -->
|
| 1152 |
+
<div class="stats-container" id="statsContainer">
|
| 1153 |
+
<div class="stat-item" style="border-left: 4px solid #007bff;">
|
| 1154 |
+
<span class="stat-number" id="statTotal">0</span>
|
| 1155 |
+
<span class="stat-label">总计</span>
|
| 1156 |
+
</div>
|
| 1157 |
+
<div class="stat-item" style="border-left: 4px solid #28a745;">
|
| 1158 |
+
<span class="stat-number" id="statNormal">0</span>
|
| 1159 |
+
<span class="stat-label">正常</span>
|
| 1160 |
+
</div>
|
| 1161 |
+
<div class="stat-item" style="border-left: 4px solid #6c757d;">
|
| 1162 |
+
<span class="stat-number" id="statDisabled">0</span>
|
| 1163 |
+
<span class="stat-label">禁用</span>
|
| 1164 |
+
</div>
|
| 1165 |
+
</div>
|
| 1166 |
+
|
| 1167 |
+
<div style="display: flex; gap: 8px; margin: 15px 0; flex-wrap: wrap;">
|
| 1168 |
+
<button class="btn btn-small" onclick="refreshCredsStatus()"
|
| 1169 |
+
style="background-color: #17a2b8;">刷新状态</button>
|
| 1170 |
+
<button class="btn btn-small" onclick="downloadAllCreds()"
|
| 1171 |
+
style="background-color: #28a745;">打包下载</button>
|
| 1172 |
+
</div>
|
| 1173 |
+
|
| 1174 |
+
<!-- 批量操作控件 -->
|
| 1175 |
+
<div class="card" style="margin: 15px 0;">
|
| 1176 |
+
<h4 style="margin-top: 0; margin-bottom: 10px; font-size: 16px;">批量操作</h4>
|
| 1177 |
+
<div style="margin-bottom: 10px;">
|
| 1178 |
+
<label style="display: flex; align-items: center; cursor: pointer; font-size: 14px;">
|
| 1179 |
+
<input type="checkbox" id="selectAllCheckbox" onchange="toggleSelectAll()"
|
| 1180 |
+
style="margin-right: 8px; transform: scale(1.2);">
|
| 1181 |
+
全选
|
| 1182 |
+
</label>
|
| 1183 |
+
<span id="selectedCount" style="font-weight: bold; color: #007bff; font-size: 12px;">已选择 0
|
| 1184 |
+
项</span>
|
| 1185 |
+
</div>
|
| 1186 |
+
<div class="batch-controls-grid">
|
| 1187 |
+
<button class="btn btn-small" id="batchEnableBtn" onclick="batchAction('enable')" disabled
|
| 1188 |
+
style="background-color: #28a745;">批量启用</button>
|
| 1189 |
+
<button class="btn btn-small" id="batchDisableBtn" onclick="batchAction('disable')" disabled
|
| 1190 |
+
style="background-color: #6c757d;">批量禁用</button>
|
| 1191 |
+
<button class="btn btn-small" id="batchDeleteBtn" onclick="batchAction('delete')" disabled
|
| 1192 |
+
style="background-color: #dc3545;">批量删除</button>
|
| 1193 |
+
<button class="btn btn-small" id="batchVerifyBtn" onclick="batchVerifyProjectIds()" disabled
|
| 1194 |
+
style="background-color: #ff9800;">批量检验</button>
|
| 1195 |
+
<button class="btn btn-small" onclick="refreshAllEmails()"
|
| 1196 |
+
style="background-color: #17a2b8;">刷新所有邮箱</button>
|
| 1197 |
+
<button class="btn btn-small" onclick="deduplicateByEmail()"
|
| 1198 |
+
style="background-color: #e91e63;">凭证一键去重</button>
|
| 1199 |
+
</div>
|
| 1200 |
+
</div>
|
| 1201 |
+
|
| 1202 |
+
<!-- 筛选控件 -->
|
| 1203 |
+
<div
|
| 1204 |
+
style="background-color: #f8f9fa; border: 1px solid #e1e4e8; border-radius: 8px; padding: 12px; margin: 15px 0;">
|
| 1205 |
+
<div class="form-group" style="margin-bottom: 8px;">
|
| 1206 |
+
<label for="statusFilter">状态筛选:</label>
|
| 1207 |
+
<select id="statusFilter" onchange="applyStatusFilter()">
|
| 1208 |
+
<option value="all">全部凭证</option>
|
| 1209 |
+
<option value="enabled">仅启用</option>
|
| 1210 |
+
<option value="disabled">仅禁用</option>
|
| 1211 |
+
</select>
|
| 1212 |
+
</div>
|
| 1213 |
+
|
| 1214 |
+
<div class="form-group" style="margin-bottom: 8px;">
|
| 1215 |
+
<label for="errorCodeFilter">错误码筛选:</label>
|
| 1216 |
+
<select id="errorCodeFilter" onchange="applyStatusFilter()">
|
| 1217 |
+
<option value="all">全部</option>
|
| 1218 |
+
<option value="400">400</option>
|
| 1219 |
+
<option value="403">403</option>
|
| 1220 |
+
<option value="429">429</option>
|
| 1221 |
+
<option value="500">500</option>
|
| 1222 |
+
</select>
|
| 1223 |
+
</div>
|
| 1224 |
+
|
| 1225 |
+
<div class="form-group" style="margin-bottom: 8px;">
|
| 1226 |
+
<label for="cooldownFilter">冷却状态:</label>
|
| 1227 |
+
<select id="cooldownFilter" onchange="applyStatusFilter()">
|
| 1228 |
+
<option value="all">全部</option>
|
| 1229 |
+
<option value="in_cooldown">CD中</option>
|
| 1230 |
+
<option value="no_cooldown">未CD</option>
|
| 1231 |
+
</select>
|
| 1232 |
+
</div>
|
| 1233 |
+
|
| 1234 |
+
<div class="form-group" style="margin-bottom: 0;">
|
| 1235 |
+
<label for="pageSizeSelect">每页显示:</label>
|
| 1236 |
+
<select id="pageSizeSelect" onchange="changePageSize()">
|
| 1237 |
+
<option value="10">10</option>
|
| 1238 |
+
<option value="20" selected>20</option>
|
| 1239 |
+
<option value="50">50</option>
|
| 1240 |
+
<option value="100">100</option>
|
| 1241 |
+
</select>
|
| 1242 |
+
</div>
|
| 1243 |
+
</div>
|
| 1244 |
+
|
| 1245 |
+
<div id="credsListSection">
|
| 1246 |
+
<div class="loading" id="credsLoading">正在加载凭证文件...</div>
|
| 1247 |
+
<div id="credsList"></div>
|
| 1248 |
+
|
| 1249 |
+
<!-- 分页控件 -->
|
| 1250 |
+
<div id="paginationContainer" style="display: none; text-align: center; margin: 20px 0;">
|
| 1251 |
+
<div
|
| 1252 |
+
style="display: flex; justify-content: center; align-items: center; gap: 10px; flex-wrap: wrap;">
|
| 1253 |
+
<button class="btn btn-small" id="prevPageBtn" onclick="changePage(-1)"
|
| 1254 |
+
style="background-color: #6c757d;">上一页</button>
|
| 1255 |
+
<div id="paginationInfo" style="font-size: 14px; color: #666;">第 1 页,共 1 页</div>
|
| 1256 |
+
<button class="btn btn-small" id="nextPageBtn" onclick="changePage(1)"
|
| 1257 |
+
style="background-color: #6c757d;">下一页</button>
|
| 1258 |
+
</div>
|
| 1259 |
+
<div
|
| 1260 |
+
style="margin-top: 10px; display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px;">
|
| 1261 |
+
<div>
|
| 1262 |
+
<label for="pageSizeSelect" style="font-size: 12px; margin-right: 5px;">每页显示:</label>
|
| 1263 |
+
<select id="pageSizeSelect" onchange="changePageSize()"
|
| 1264 |
+
style="padding: 4px; font-size: 12px;">
|
| 1265 |
+
<option value="10">10</option>
|
| 1266 |
+
<option value="20" selected>20</option>
|
| 1267 |
+
<option value="50">50</option>
|
| 1268 |
+
<option value="100">100</option>
|
| 1269 |
+
</select>
|
| 1270 |
+
</div>
|
| 1271 |
+
</div>
|
| 1272 |
+
</div>
|
| 1273 |
+
</div>
|
| 1274 |
+
</div>
|
| 1275 |
+
|
| 1276 |
+
<!-- Antigravity凭证管理标签页 -->
|
| 1277 |
+
<div id="antigravity-manageTab" class="tab-content">
|
| 1278 |
+
<h3>Antigravity凭证文件管理</h3>
|
| 1279 |
+
<p>管理所有Antigravity认证文件,查看状态和执行操作</p>
|
| 1280 |
+
|
| 1281 |
+
<!-- 检验功能说明 -->
|
| 1282 |
+
<div class="status info" style="margin-bottom: 15px;">
|
| 1283 |
+
<strong>💡 检验功能说明:</strong>
|
| 1284 |
+
<p style="margin: 8px 0; font-size: 14px;">
|
| 1285 |
+
点击每个凭证的"检验"按钮可以重新获取Project ID。<br>
|
| 1286 |
+
<strong style="color: #0c5460;">✅ 检验成功可以恢复403错误</strong>,让凭证重新正常工作。<br>
|
| 1287 |
+
建议在遇到403错误时使用此功能。
|
| 1288 |
+
</p>
|
| 1289 |
+
</div>
|
| 1290 |
+
|
| 1291 |
+
<!-- 状态统计 -->
|
| 1292 |
+
<div class="stats-container" id="antigravityStatsContainer">
|
| 1293 |
+
<div class="stat-item" style="border-left: 4px solid #007bff;">
|
| 1294 |
+
<span class="stat-number" id="antigravityStatTotal">0</span>
|
| 1295 |
+
<span class="stat-label">总计</span>
|
| 1296 |
+
</div>
|
| 1297 |
+
<div class="stat-item" style="border-left: 4px solid #28a745;">
|
| 1298 |
+
<span class="stat-number" id="antigravityStatNormal">0</span>
|
| 1299 |
+
<span class="stat-label">正常</span>
|
| 1300 |
+
</div>
|
| 1301 |
+
<div class="stat-item" style="border-left: 4px solid #6c757d;">
|
| 1302 |
+
<span class="stat-number" id="antigravityStatDisabled">0</span>
|
| 1303 |
+
<span class="stat-label">禁用</span>
|
| 1304 |
+
</div>
|
| 1305 |
+
</div>
|
| 1306 |
+
|
| 1307 |
+
<div style="display: flex; gap: 8px; margin: 15px 0; flex-wrap: wrap;">
|
| 1308 |
+
<button class="btn btn-small" onclick="refreshAntigravityCredsList()"
|
| 1309 |
+
style="background-color: #17a2b8;">刷新状态</button>
|
| 1310 |
+
<button class="btn btn-small" onclick="downloadAllAntigravityCreds()"
|
| 1311 |
+
style="background-color: #28a745;">打包下载</button>
|
| 1312 |
+
</div>
|
| 1313 |
+
|
| 1314 |
+
<!-- 批量操作控件 -->
|
| 1315 |
+
<div class="card" style="margin: 15px 0;">
|
| 1316 |
+
<h4 style="margin-top: 0; margin-bottom: 10px; font-size: 16px;">批量操作</h4>
|
| 1317 |
+
<div style="margin-bottom: 10px;">
|
| 1318 |
+
<label style="display: flex; align-items: center; cursor: pointer; font-size: 14px;">
|
| 1319 |
+
<input type="checkbox" id="selectAllAntigravityCheckbox"
|
| 1320 |
+
onchange="toggleSelectAllAntigravity()"
|
| 1321 |
+
style="margin-right: 8px; transform: scale(1.2);">
|
| 1322 |
+
全选
|
| 1323 |
+
</label>
|
| 1324 |
+
<span id="antigravitySelectedCount"
|
| 1325 |
+
style="font-weight: bold; color: #007bff; font-size: 12px;">已选择 0
|
| 1326 |
+
项</span>
|
| 1327 |
+
</div>
|
| 1328 |
+
<div class="batch-controls-grid">
|
| 1329 |
+
<button class="btn btn-small" id="antigravityBatchEnableBtn"
|
| 1330 |
+
onclick="batchAntigravityAction('enable')" disabled
|
| 1331 |
+
style="background-color: #28a745;">批量启用</button>
|
| 1332 |
+
<button class="btn btn-small" id="antigravityBatchDisableBtn"
|
| 1333 |
+
onclick="batchAntigravityAction('disable')" disabled
|
| 1334 |
+
style="background-color: #6c757d;">批量禁用</button>
|
| 1335 |
+
<button class="btn btn-small" id="antigravityBatchDeleteBtn"
|
| 1336 |
+
onclick="batchAntigravityAction('delete')" disabled
|
| 1337 |
+
style="background-color: #dc3545;">批量删除</button>
|
| 1338 |
+
<button class="btn btn-small" id="antigravityBatchVerifyBtn"
|
| 1339 |
+
onclick="batchVerifyAntigravityProjectIds()" disabled
|
| 1340 |
+
style="background-color: #ff9800;">批量检验</button>
|
| 1341 |
+
<button class="btn btn-small" onclick="refreshAllAntigravityEmails()"
|
| 1342 |
+
style="background-color: #17a2b8;">刷新所有邮箱</button>
|
| 1343 |
+
<button class="btn btn-small" onclick="deduplicateAntigravityByEmail()"
|
| 1344 |
+
style="background-color: #e91e63;">凭证一键去重</button>
|
| 1345 |
+
</div>
|
| 1346 |
+
</div>
|
| 1347 |
+
|
| 1348 |
+
<!-- 筛选控件 -->
|
| 1349 |
+
<div
|
| 1350 |
+
style="background-color: #f8f9fa; border: 1px solid #e1e4e8; border-radius: 8px; padding: 12px; margin: 15px 0;">
|
| 1351 |
+
<div class="form-group" style="margin-bottom: 8px;">
|
| 1352 |
+
<label for="antigravityStatusFilter">状态筛选:</label>
|
| 1353 |
+
<select id="antigravityStatusFilter" onchange="applyAntigravityStatusFilter()">
|
| 1354 |
+
<option value="all">全部凭证</option>
|
| 1355 |
+
<option value="enabled">仅启用</option>
|
| 1356 |
+
<option value="disabled">仅禁用</option>
|
| 1357 |
+
</select>
|
| 1358 |
+
</div>
|
| 1359 |
+
|
| 1360 |
+
<div class="form-group" style="margin-bottom: 8px;">
|
| 1361 |
+
<label for="antigravityErrorCodeFilter">错误码筛选:</label>
|
| 1362 |
+
<select id="antigravityErrorCodeFilter" onchange="applyAntigravityStatusFilter()">
|
| 1363 |
+
<option value="all">全部</option>
|
| 1364 |
+
<option value="400">400</option>
|
| 1365 |
+
<option value="403">403</option>
|
| 1366 |
+
<option value="429">429</option>
|
| 1367 |
+
<option value="500">500</option>
|
| 1368 |
+
</select>
|
| 1369 |
+
</div>
|
| 1370 |
+
|
| 1371 |
+
<div class="form-group" style="margin-bottom: 8px;">
|
| 1372 |
+
<label for="antigravityCooldownFilter">冷却状态:</label>
|
| 1373 |
+
<select id="antigravityCooldownFilter" onchange="applyAntigravityStatusFilter()">
|
| 1374 |
+
<option value="all">全部</option>
|
| 1375 |
+
<option value="in_cooldown">CD中</option>
|
| 1376 |
+
<option value="no_cooldown">未CD</option>
|
| 1377 |
+
</select>
|
| 1378 |
+
</div>
|
| 1379 |
+
|
| 1380 |
+
<div class="form-group" style="margin-bottom: 0;">
|
| 1381 |
+
<label for="antigravityPageSizeSelect">每页显示:</label>
|
| 1382 |
+
<select id="antigravityPageSizeSelect" onchange="changeAntigravityPageSize()">
|
| 1383 |
+
<option value="10">10</option>
|
| 1384 |
+
<option value="20" selected>20</option>
|
| 1385 |
+
<option value="50">50</option>
|
| 1386 |
+
<option value="100">100</option>
|
| 1387 |
+
</select>
|
| 1388 |
+
</div>
|
| 1389 |
+
</div>
|
| 1390 |
+
|
| 1391 |
+
<div id="antigravityCredsListSection">
|
| 1392 |
+
<div class="loading" id="antigravityCredsLoading">正在加载凭证文件...</div>
|
| 1393 |
+
<div id="antigravityCredsList"></div>
|
| 1394 |
+
|
| 1395 |
+
<!-- 分页控件 -->
|
| 1396 |
+
<div id="antigravityPaginationContainer" style="display: none; text-align: center; margin: 20px 0;">
|
| 1397 |
+
<div
|
| 1398 |
+
style="display: flex; justify-content: center; align-items: center; gap: 10px; flex-wrap: wrap;">
|
| 1399 |
+
<button class="btn btn-small" id="antigravityPrevPageBtn"
|
| 1400 |
+
onclick="changeAntigravityPage(-1)" style="background-color: #6c757d;">上一页</button>
|
| 1401 |
+
<div id="antigravityPaginationInfo" style="font-size: 14px; color: #666;">第 1 页,共 1 页</div>
|
| 1402 |
+
<button class="btn btn-small" id="antigravityNextPageBtn" onclick="changeAntigravityPage(1)"
|
| 1403 |
+
style="background-color: #6c757d;">下一页</button>
|
| 1404 |
+
</div>
|
| 1405 |
+
</div>
|
| 1406 |
+
</div>
|
| 1407 |
+
</div>
|
| 1408 |
+
|
| 1409 |
+
<div id="configTab" class="tab-content">
|
| 1410 |
+
<h3>配置管理</h3>
|
| 1411 |
+
<p>管理系统配置参数,修改后立即生效</p>
|
| 1412 |
+
|
| 1413 |
+
<div style="display: flex; gap: 8px; margin: 15px 0; flex-wrap: wrap;">
|
| 1414 |
+
<button class="btn btn-small" onclick="loadConfig()"
|
| 1415 |
+
style="background-color: #17a2b8;">刷新配置</button>
|
| 1416 |
+
<button class="btn btn-small" onclick="saveConfig()">保存配置</button>
|
| 1417 |
+
</div>
|
| 1418 |
+
|
| 1419 |
+
<div id="configSection">
|
| 1420 |
+
<div class="loading" id="configLoading">正在加载配置...</div>
|
| 1421 |
+
<div id="configForm" class="hidden">
|
| 1422 |
+
<div class="card">
|
| 1423 |
+
<h4 style="margin-top: 0; margin-bottom: 15px;">服务器配置</h4>
|
| 1424 |
+
|
| 1425 |
+
<div class="form-group">
|
| 1426 |
+
<label for="host">服务器主机地址:</label>
|
| 1427 |
+
<input type="text" id="host" placeholder="例如: 0.0.0.0, 127.0.0.1" />
|
| 1428 |
+
<small
|
| 1429 |
+
style="display: block; color: #666; font-size: 12px; margin-top: 5px;">服务器监听的主机地址,0.0.0.0表示监听所有接口</small>
|
| 1430 |
+
</div>
|
| 1431 |
+
|
| 1432 |
+
<div class="form-group">
|
| 1433 |
+
<label for="port">服务器端口:</label>
|
| 1434 |
+
<input type="number" id="port" min="1" max="65535" placeholder="7861" />
|
| 1435 |
+
<small
|
| 1436 |
+
style="display: block; color: #666; font-size: 12px; margin-top: 5px;">服务器监听的端口号,修改后需要重启服务器</small>
|
| 1437 |
+
</div>
|
| 1438 |
+
|
| 1439 |
+
<div class="form-group">
|
| 1440 |
+
<label for="configApiPassword">API访问密码:</label>
|
| 1441 |
+
<input type="text" id="configApiPassword" placeholder="pwd" />
|
| 1442 |
+
<small
|
| 1443 |
+
style="display: block; color: #666; font-size: 12px; margin-top: 5px;">聊天API访问密码,用于OpenAI和Gemini
|
| 1444 |
+
API端点的认证</small>
|
| 1445 |
+
</div>
|
| 1446 |
+
|
| 1447 |
+
<div class="form-group">
|
| 1448 |
+
<label for="configPanelPassword">控制面板密码:</label>
|
| 1449 |
+
<input type="text" id="configPanelPassword" placeholder="pwd" />
|
| 1450 |
+
<small
|
| 1451 |
+
style="display: block; color: #666; font-size: 12px; margin-top: 5px;">控制面板访问密码,用于web界面登录认证</small>
|
| 1452 |
+
</div>
|
| 1453 |
+
|
| 1454 |
+
<div class="form-group">
|
| 1455 |
+
<label for="configPassword">通用密码:</label>
|
| 1456 |
+
<input type="text" id="configPassword" placeholder="pwd" />
|
| 1457 |
+
<small
|
| 1458 |
+
style="display: block; color: #666; font-size: 12px; margin-top: 5px;">(兼容性保留)设置后将覆盖上述两个密码,留空则使用分开的密码设置</small>
|
| 1459 |
+
</div>
|
| 1460 |
+
</div>
|
| 1461 |
+
|
| 1462 |
+
<div class="card">
|
| 1463 |
+
<h4 style="margin-top: 0; margin-bottom: 15px;">基础配置</h4>
|
| 1464 |
+
|
| 1465 |
+
<div class="form-group">
|
| 1466 |
+
<label for="credentialsDir">凭证目录路径:</label>
|
| 1467 |
+
<input type="text" id="credentialsDir" />
|
| 1468 |
+
<small
|
| 1469 |
+
style="display: block; color: #666; font-size: 12px; margin-top: 5px;">存储认证文件的目录路径</small>
|
| 1470 |
+
</div>
|
| 1471 |
+
|
| 1472 |
+
<div class="form-group">
|
| 1473 |
+
<label for="proxy">代理设置:</label>
|
| 1474 |
+
<input type="text" id="proxy"
|
| 1475 |
+
placeholder="例如: http://proxy:11451 或 socks5://proxy:1080" />
|
| 1476 |
+
<small
|
| 1477 |
+
style="display: block; color: #666; font-size: 12px; margin-top: 5px;">HTTP/HTTPS/SOCKS5Endpoint,留空表示不使用代理</small>
|
| 1478 |
+
</div>
|
| 1479 |
+
</div>
|
| 1480 |
+
|
| 1481 |
+
<div class="card">
|
| 1482 |
+
<h4 style="margin-top: 0; margin-bottom: 15px;">端点配置</h4>
|
| 1483 |
+
|
| 1484 |
+
<!-- 快速配置按钮 -->
|
| 1485 |
+
<div class="form-group">
|
| 1486 |
+
<div style="display: flex; gap: 8px; margin-bottom: 15px; flex-wrap: wrap;">
|
| 1487 |
+
<button type="button" class="btn" onclick="useMirrorUrls()"
|
| 1488 |
+
style="background-color: #28a745; font-size: 13px; flex: 1; min-width: 120px;">
|
| 1489 |
+
🚀 镜像网址
|
| 1490 |
+
</button>
|
| 1491 |
+
<button type="button" class="btn" onclick="restoreOfficialUrls()"
|
| 1492 |
+
style="background-color: #17a2b8; font-size: 13px; flex: 1; min-width: 120px;">
|
| 1493 |
+
🔄 官方端点
|
| 1494 |
+
</button>
|
| 1495 |
+
</div>
|
| 1496 |
+
<small
|
| 1497 |
+
style="display: block; color: #666; font-size: 11px; margin-bottom: 10px;">镜像网址主要解决墙内无法访问官方端点的问题,部分地区可能无法使用</small>
|
| 1498 |
+
</div>
|
| 1499 |
+
|
| 1500 |
+
<div class="form-group">
|
| 1501 |
+
<label for="codeAssistEndpoint">Code Assist Endpoint:</label>
|
| 1502 |
+
<input type="text" id="codeAssistEndpoint" />
|
| 1503 |
+
<small style="display: block; color: #666; font-size: 12px; margin-top: 5px;">Google
|
| 1504 |
+
Cloud Code Assist API端点地址</small>
|
| 1505 |
+
</div>
|
| 1506 |
+
|
| 1507 |
+
<div class="form-group">
|
| 1508 |
+
<label for="oauthProxyUrl">OAuth Endpoint:</label>
|
| 1509 |
+
<input type="text" id="oauthProxyUrl" placeholder="https://oauth2.googleapis.com" />
|
| 1510 |
+
<small style="display: block; color: #666; font-size: 12px; margin-top: 5px;">Google
|
| 1511 |
+
OAuth2 API端点地址,用于token获取和刷新</small>
|
| 1512 |
+
</div>
|
| 1513 |
+
|
| 1514 |
+
<div class="form-group">
|
| 1515 |
+
<label for="googleapisProxyUrl">Google APIs Endpoint:</label>
|
| 1516 |
+
<input type="text" id="googleapisProxyUrl" placeholder="https://www.googleapis.com" />
|
| 1517 |
+
<small style="display: block; color: #666; font-size: 12px; margin-top: 5px;">Google
|
| 1518 |
+
APIs API端点地址,用于API服务调用</small>
|
| 1519 |
+
</div>
|
| 1520 |
+
|
| 1521 |
+
|
| 1522 |
+
<div class="form-group">
|
| 1523 |
+
<label for="resourceManagerApiUrl">Resource Manager API Endpoint:</label>
|
| 1524 |
+
<input type="text" id="resourceManagerApiUrl"
|
| 1525 |
+
placeholder="https://cloudresourcemanager.googleapis.com" />
|
| 1526 |
+
<small style="display: block; color: #666; font-size: 12px; margin-top: 5px;">Google
|
| 1527 |
+
Cloud Resource Manager API端点地址,用于项目管理</small>
|
| 1528 |
+
</div>
|
| 1529 |
+
|
| 1530 |
+
<div class="form-group">
|
| 1531 |
+
<label for="serviceUsageApiUrl">Service Usage API Endpoint:</label>
|
| 1532 |
+
<input type="text" id="serviceUsageApiUrl"
|
| 1533 |
+
placeholder="https://serviceusage.googleapis.com" />
|
| 1534 |
+
<small style="display: block; color: #666; font-size: 12px; margin-top: 5px;">Google
|
| 1535 |
+
Cloud Service Usage API端点地址,用于服务启用管理</small>
|
| 1536 |
+
</div>
|
| 1537 |
+
|
| 1538 |
+
<div class="form-group">
|
| 1539 |
+
<label for="antigravityApiUrl">Antigravity API Endpoint:</label>
|
| 1540 |
+
<input type="text" id="antigravityApiUrl"
|
| 1541 |
+
placeholder="https://daily-cloudcode-pa.sandbox.googleapis.com" />
|
| 1542 |
+
<small style="display: block; color: #666; font-size: 12px; margin-top: 5px;">Google
|
| 1543 |
+
Antigravity API端点地址,用于反重力模式</small>
|
| 1544 |
+
</div>
|
| 1545 |
+
</div>
|
| 1546 |
+
|
| 1547 |
+
<div class="card">
|
| 1548 |
+
<h4 style="margin-top: 0; margin-bottom: 15px;">自动封禁配置</h4>
|
| 1549 |
+
|
| 1550 |
+
<div class="form-group">
|
| 1551 |
+
<label style="display: flex; align-items: center; cursor: pointer;">
|
| 1552 |
+
<input type="checkbox" id="autoBanEnabled" style="margin-right: 8px;" />
|
| 1553 |
+
启用自动封禁
|
| 1554 |
+
</label>
|
| 1555 |
+
<small
|
| 1556 |
+
style="display: block; color: #666; font-size: 12px; margin-top: 5px;">遇到指定错误码时自动禁用凭证</small>
|
| 1557 |
+
</div>
|
| 1558 |
+
|
| 1559 |
+
<div class="form-group">
|
| 1560 |
+
<label for="autoBanErrorCodes">自动封禁错误码:</label>
|
| 1561 |
+
<input type="text" id="autoBanErrorCodes" placeholder="例如: 400,403" />
|
| 1562 |
+
<small
|
| 1563 |
+
style="display: block; color: #666; font-size: 12px; margin-top: 5px;">用逗号分隔的错误码列表</small>
|
| 1564 |
+
</div>
|
| 1565 |
+
</div>
|
| 1566 |
+
|
| 1567 |
+
<div class="card">
|
| 1568 |
+
<h4 style="margin-top: 0; margin-bottom: 15px;">429重试配置</h4>
|
| 1569 |
+
|
| 1570 |
+
<div class="form-group">
|
| 1571 |
+
<label style="display: flex; align-items: center; cursor: pointer;">
|
| 1572 |
+
<input type="checkbox" id="retry429Enabled" style="margin-right: 8px;" />
|
| 1573 |
+
启用429重试
|
| 1574 |
+
</label>
|
| 1575 |
+
<small
|
| 1576 |
+
style="display: block; color: #666; font-size: 12px; margin-top: 5px;">遇到429错误时自动重试</small>
|
| 1577 |
+
</div>
|
| 1578 |
+
|
| 1579 |
+
<div class="form-group">
|
| 1580 |
+
<label for="retry429MaxRetries">429重试次数:</label>
|
| 1581 |
+
<input type="number" id="retry429MaxRetries" min="1" max="50" />
|
| 1582 |
+
<small
|
| 1583 |
+
style="display: block; color: #666; font-size: 12px; margin-top: 5px;">遇到429错误时的最大重试次数</small>
|
| 1584 |
+
</div>
|
| 1585 |
+
|
| 1586 |
+
<div class="form-group">
|
| 1587 |
+
<label for="retry429Interval">429重试间隔(秒):</label>
|
| 1588 |
+
<input type="number" id="retry429Interval" min="0.01" max="10" step="0.01" />
|
| 1589 |
+
<small
|
| 1590 |
+
style="display: block; color: #666; font-size: 12px; margin-top: 5px;">遇到429错误时每两次重试间的等待时间</small>
|
| 1591 |
+
</div>
|
| 1592 |
+
</div>
|
| 1593 |
+
|
| 1594 |
+
|
| 1595 |
+
<div class="card">
|
| 1596 |
+
<h4 style="margin-top: 0; margin-bottom: 15px;">兼容性配置</h4>
|
| 1597 |
+
|
| 1598 |
+
<div class="form-group">
|
| 1599 |
+
<label style="display: flex; align-items: center; gap: 8px;">
|
| 1600 |
+
<input type="checkbox" id="compatibilityModeEnabled"
|
| 1601 |
+
style="width: auto; margin: 0;" />
|
| 1602 |
+
启用兼容性模式
|
| 1603 |
+
</label>
|
| 1604 |
+
<small
|
| 1605 |
+
style="display: block; color: #666; font-size: 12px; margin-top: 5px;">启用后所有system消息全部转换成user,停用system_instructions
|
| 1606 |
+
<span style="color: #28a745;">✓ 支持热更新</span></small>
|
| 1607 |
+
<div
|
| 1608 |
+
style="background-color: #fff3cd; border: 1px solid #ffc107; border-radius: 6px; padding: 12px; margin-top: 8px; font-size: 13px; color: #856404;">
|
| 1609 |
+
<strong>⚠️ 注意:</strong>该选项可能会降低模型理解能力,但是能避免流式空回的情况。
|
| 1610 |
+
<br><strong>适用场景:</strong>当遇到流式传输时模型不返回内容或返回空响应时启用此选项。
|
| 1611 |
+
</div>
|
| 1612 |
+
</div>
|
| 1613 |
+
|
| 1614 |
+
<div class="form-group">
|
| 1615 |
+
<label style="display: flex; align-items: center; gap: 8px;">
|
| 1616 |
+
<input type="checkbox" id="returnThoughtsToFrontend"
|
| 1617 |
+
style="width: auto; margin: 0;" />
|
| 1618 |
+
返回思维链到前端
|
| 1619 |
+
</label>
|
| 1620 |
+
<small
|
| 1621 |
+
style="display: block; color: #666; font-size: 12px; margin-top: 5px;">启用后,模型的思维链会在响应中返回;禁用后,思维链会被过滤掉
|
| 1622 |
+
<span style="color: #28a745;">✓ 支持热更新</span></small>
|
| 1623 |
+
<div
|
| 1624 |
+
style="background-color: #e3f2fd; border: 1px solid #2196f3; border-radius: 6px; padding: 12px; margin-top: 8px; font-size: 13px; color: #0d47a1;">
|
| 1625 |
+
<strong>💭 说明:</strong>某些模型(如Gemini 2.0
|
| 1626 |
+
Pro)支持thinking模式,会在生成回答前先输出思考过程。启用后可以看到模型的思考过程;禁用后只显示最终回答,让输出更简洁。
|
| 1627 |
+
</div>
|
| 1628 |
+
</div>
|
| 1629 |
+
|
| 1630 |
+
<div class="form-group">
|
| 1631 |
+
<label style="display: flex; align-items: center; gap: 8px;">
|
| 1632 |
+
<input type="checkbox" id="antigravityStream2nostream"
|
| 1633 |
+
style="width: auto; margin: 0;" />
|
| 1634 |
+
Antigravity流式转非流式
|
| 1635 |
+
</label>
|
| 1636 |
+
<small
|
| 1637 |
+
style="display: block; color: #666; font-size: 12px; margin-top: 5px;">启用后,非流式请求将使用流式API并收集为完整响应
|
| 1638 |
+
<span style="color: #28a745;">✓ 支持热更新</span></small>
|
| 1639 |
+
<div
|
| 1640 |
+
style="background-color: #f3e5f5; border: 1px solid #9c27b0; border-radius: 6px; padding: 12px; margin-top: 8px; font-size: 13px; color: #4a148c;">
|
| 1641 |
+
<strong>🔄 说明:</strong>针对Antigravity模式的优化选项。启用后,即使客户端请求非流式响应,后端也会使用流式API获取数据并收集完整后再返回。
|
| 1642 |
+
<br><strong>适用场景:</strong>某些情况下流式API比非流式API更稳定,启用此选项可以提高响应质量。
|
| 1643 |
+
<br><strong>默认:</strong>已启用
|
| 1644 |
+
</div>
|
| 1645 |
+
</div>
|
| 1646 |
+
</div>
|
| 1647 |
+
|
| 1648 |
+
<div class="card">
|
| 1649 |
+
<h4 style="margin-top: 0; margin-bottom: 15px;">抗截断配置</h4>
|
| 1650 |
+
|
| 1651 |
+
<div class="form-group">
|
| 1652 |
+
<label for="antiTruncationMaxAttempts">抗截断最大重试次数:</label>
|
| 1653 |
+
<input type="number" id="antiTruncationMaxAttempts" min="1" max="10" />
|
| 1654 |
+
<small
|
| 1655 |
+
style="display: block; color: #666; font-size: 12px; margin-top: 5px;">当检测到输出截断时的最大续传尝试次数</small>
|
| 1656 |
+
</div>
|
| 1657 |
+
|
| 1658 |
+
<div
|
| 1659 |
+
style="background-color: #e3f2fd; border: 1px solid #1976d2; border-radius: 6px; padding: 12px; margin-top: 8px; font-size: 13px; color: #1565c0;">
|
| 1660 |
+
<strong>注意:</strong>抗截断功能现在通过模型名控制:
|
| 1661 |
+
<ul style="margin: 5px 0; padding-left: 20px; color: #424242;">
|
| 1662 |
+
<li>选择带有 "-流式抗截断" 后缀的模型即可启用</li>
|
| 1663 |
+
<li>该功能仅在流式传输时生效</li>
|
| 1664 |
+
<li>例如: "gemini-2.5-pro-流式抗截断"</li>
|
| 1665 |
+
</ul>
|
| 1666 |
+
</div>
|
| 1667 |
+
</div>
|
| 1668 |
+
|
| 1669 |
+
<div class="card">
|
| 1670 |
+
<h4 style="margin-top: 0; margin-bottom: 15px;">配置热更新说明</h4>
|
| 1671 |
+
|
| 1672 |
+
<div
|
| 1673 |
+
style="background-color: #d4edda; border: 1px solid #c3e6cb; border-radius: 6px; padding: 12px; font-size: 13px; color: #155724; margin-bottom: 10px;">
|
| 1674 |
+
<strong>🔥 热更新配置(立即生效):</strong>
|
| 1675 |
+
<ul style="margin: 8px 0; padding-left: 20px; color: #212529;">
|
| 1676 |
+
<li><strong>网络配置:</strong>代理、端点配置、超时、连接数</li>
|
| 1677 |
+
<li><strong>API配置:</strong>轮换次数、重试设置、自动封禁</li>
|
| 1678 |
+
<li><strong>密码配置:</strong>API密码、面板密码</li>
|
| 1679 |
+
<li><strong>功能配置:</strong>抗截断重试次数</li>
|
| 1680 |
+
</ul>
|
| 1681 |
+
</div>
|
| 1682 |
+
|
| 1683 |
+
<div
|
| 1684 |
+
style="background-color: #fff3cd; border: 1px solid #ffc107; border-radius: 6px; padding: 12px; font-size: 13px; color: #856404; margin-bottom: 10px;">
|
| 1685 |
+
<strong>🔄 需要重启的配置:</strong>
|
| 1686 |
+
<ul style="margin: 8px 0; padding-left: 20px; color: #212529;">
|
| 1687 |
+
<li><strong>服务器配置:</strong>主机地址、端口号</li>
|
| 1688 |
+
<li><strong>文件路径:</strong>凭证目录</li>
|
| 1689 |
+
</ul>
|
| 1690 |
+
</div>
|
| 1691 |
+
|
| 1692 |
+
</div>
|
| 1693 |
+
</div>
|
| 1694 |
+
</div>
|
| 1695 |
+
</div>
|
| 1696 |
+
|
| 1697 |
+
<div id="logsTab" class="tab-content">
|
| 1698 |
+
<h3>实时日志</h3>
|
| 1699 |
+
<p>查看系统实时日志输出,支持日志筛选和自动滚动</p>
|
| 1700 |
+
|
| 1701 |
+
<div style="display: flex; gap: 8px; margin: 15px 0; flex-wrap: wrap;">
|
| 1702 |
+
<button class="btn btn-small" onclick="connectWebSocket()"
|
| 1703 |
+
style="background-color: #17a2b8;">连接日志流</button>
|
| 1704 |
+
<button class="btn btn-small" onclick="disconnectWebSocket()"
|
| 1705 |
+
style="background-color: #dc3545;">断开连接</button>
|
| 1706 |
+
<button class="btn btn-small" onclick="downloadLogs()"
|
| 1707 |
+
style="background-color: #28a745;">下载日志</button>
|
| 1708 |
+
<button class="btn btn-small" onclick="clearLogs()" style="background-color: #6c757d;">清空日志</button>
|
| 1709 |
+
</div>
|
| 1710 |
+
|
| 1711 |
+
<div
|
| 1712 |
+
style="background-color: #f8f9fa; border: 1px solid #e1e4e8; border-radius: 8px; padding: 12px; margin: 15px 0;">
|
| 1713 |
+
<div class="form-group" style="margin-bottom: 8px;">
|
| 1714 |
+
<label for="logLevelFilter">日志级别筛选:</label>
|
| 1715 |
+
<select id="logLevelFilter" onchange="filterLogs()">
|
| 1716 |
+
<option value="all">全部</option>
|
| 1717 |
+
<option value="ERROR">错误</option>
|
| 1718 |
+
<option value="WARNING">警告</option>
|
| 1719 |
+
<option value="INFO">信息</option>
|
| 1720 |
+
<option value="DEBUG">调试</option>
|
| 1721 |
+
</select>
|
| 1722 |
+
</div>
|
| 1723 |
+
|
| 1724 |
+
<div class="form-group" style="margin-bottom: 0;">
|
| 1725 |
+
<label style="display: flex; align-items: center; cursor: pointer;">
|
| 1726 |
+
<input type="checkbox" id="autoScroll" checked style="margin-right: 8px;" />
|
| 1727 |
+
自动滚动到底部
|
| 1728 |
+
</label>
|
| 1729 |
+
</div>
|
| 1730 |
+
</div>
|
| 1731 |
+
|
| 1732 |
+
<div id="logConnectionStatus" class="status info">
|
| 1733 |
+
<strong>连接状态:</strong> <span id="connectionStatusText">未连接</span>
|
| 1734 |
+
</div>
|
| 1735 |
+
|
| 1736 |
+
<div id="logContainer"
|
| 1737 |
+
style="background-color: #1e1e1e; color: #ffffff; font-family: 'Courier New', monospace; font-size: 12px; height: 600px; overflow-y: auto; border: 1px solid #333; border-radius: 5px; padding: 15px; white-space: pre-wrap; word-break: break-all;">
|
| 1738 |
+
<div id="logContent">等待连接日志流...</div>
|
| 1739 |
+
</div>
|
| 1740 |
+
</div>
|
| 1741 |
+
|
| 1742 |
+
<!-- 项目信息标签页 -->
|
| 1743 |
+
<div id="aboutTab" class="tab-content">
|
| 1744 |
+
<h3>项目信息</h3>
|
| 1745 |
+
<p>关于GCLI2API项目的详细信息</p>
|
| 1746 |
+
|
| 1747 |
+
<!-- 项目介绍 -->
|
| 1748 |
+
<div class="card">
|
| 1749 |
+
<h4 style="margin-top: 0; color: #007bff;">📋 项目简介</h4>
|
| 1750 |
+
<p style="margin: 10px 0; line-height: 1.6; color: #495057; font-size: 14px;">
|
| 1751 |
+
GCLI2API是一个将Google Gemini API转换为OpenAI 和GEMINI API格式的代理工具,支持多账户管理、自动轮换、实时日志监控等功能。
|
| 1752 |
+
</p>
|
| 1753 |
+
<div style="margin: 15px 0; font-size: 14px;">
|
| 1754 |
+
<p style="margin: 5px 0;"><strong>🔗 项目地址:</strong> <a
|
| 1755 |
+
href="https://github.com/su-kaka/gcli2api" target="_blank"
|
| 1756 |
+
style="color: #007bff; text-decoration: none;">GitHub - su-kaka/gcli2api</a></p>
|
| 1757 |
+
<p style="margin: 5px 0;"><strong>⚠️ 使用声明:</strong> <span
|
| 1758 |
+
style="color: #dc3545; font-weight: 500;">禁止商业用途和倒卖 - 仅供学习使用</span></p>
|
| 1759 |
+
</div>
|
| 1760 |
+
</div>
|
| 1761 |
+
|
| 1762 |
+
<!-- 功能特性 -->
|
| 1763 |
+
<div class="card">
|
| 1764 |
+
<h4 style="margin-top: 0; color: #17a2b8;">✨ 主要功能</h4>
|
| 1765 |
+
<div style="font-size: 14px; line-height: 1.6;">
|
| 1766 |
+
<p><strong>🔄 多账户管理:</strong> 支持批量上传和管理多个Google账户</p>
|
| 1767 |
+
<p><strong>⚡ 自动轮换:</strong> 智能轮换账户,避免单账户限额</p>
|
| 1768 |
+
<p><strong>📊 实时监控:</strong> 使用统计、错误监控、实时日志</p>
|
| 1769 |
+
<p><strong>🛡️ 安全可靠:</strong> OAuth2认证、自动封禁异常账户</p>
|
| 1770 |
+
<p><strong>🎛️ 配置灵活:</strong> 支持热更新配置、代理设置</p>
|
| 1771 |
+
<p><strong>📱 界面友好:</strong> 响应式设计、移动端适配</p>
|
| 1772 |
+
</div>
|
| 1773 |
+
</div>
|
| 1774 |
+
|
| 1775 |
+
<!-- 交流群 -->
|
| 1776 |
+
<div class="card" style="border-left: 4px solid #4285f4; text-align: center;">
|
| 1777 |
+
<h4 style="margin-top: 0; color: #1976d2;">💬 交流群</h4>
|
| 1778 |
+
<div style="color: #1565c0; line-height: 1.6; font-size: 14px;">
|
| 1779 |
+
<p>欢迎加入 QQ 群交流讨论!</p>
|
| 1780 |
+
<p style="font-size: 16px; font-weight: bold; color: #4285f4;">QQ 群号:937681997</p>
|
| 1781 |
+
</div>
|
| 1782 |
+
<div
|
| 1783 |
+
style="display: inline-block; background: white; padding: 12px; border-radius: 10px; box-shadow: 0 2px 15px rgba(0,0,0,0.1); margin-top: 10px;">
|
| 1784 |
+
<img src="docs/qq群.jpg" alt="QQ群二维码"
|
| 1785 |
+
style="width: 180px; height: 180px; border-radius: 6px; display: block;">
|
| 1786 |
+
<p
|
| 1787 |
+
style="color: #666; margin: 8px 0 0 0; font-size: 12px; font-weight: 600; text-align: center;">
|
| 1788 |
+
扫码加入QQ群</p>
|
| 1789 |
+
</div>
|
| 1790 |
+
</div>
|
| 1791 |
+
|
| 1792 |
+
<!-- 联系和反馈 -->
|
| 1793 |
+
<div class="card">
|
| 1794 |
+
<h4 style="margin-top: 0; color: #0c5460;">📞 联系我们</h4>
|
| 1795 |
+
<div style="color: #0c5460; line-height: 1.6; font-size: 14px;">
|
| 1796 |
+
<p>• <strong>问题反馈:</strong> 通过GitHub Issues提交问题和建议</p>
|
| 1797 |
+
<p>• <strong>功能请求:</strong> 在GitHub Discussions中讨论新功能</p>
|
| 1798 |
+
<p>• <strong>代码贡献:</strong> 欢迎提交Pull Request改进项目</p>
|
| 1799 |
+
<p>• <strong>文档完善:</strong> 帮助改进项目文档和使用指南</p>
|
| 1800 |
+
</div>
|
| 1801 |
+
</div>
|
| 1802 |
+
</div>
|
| 1803 |
+
|
| 1804 |
+
<div id="statusSection"></div>
|
| 1805 |
+
|
| 1806 |
+
<!-- 项目信息 -->
|
| 1807 |
+
<div
|
| 1808 |
+
style="background-color: #f8f9fa; border: 1px solid #dee2e6; border-radius: 6px; padding: 10px; margin-top: 20px; text-align: center; border-left: 4px solid #007bff;">
|
| 1809 |
+
<p style="margin: 4px 0; font-size: 12px; color: #495057;">GitHub: <a
|
| 1810 |
+
href="https://github.com/su-kaka/gcli2api" target="_blank"
|
| 1811 |
+
style="color: #007bff; text-decoration: none;">github.com/su-kaka/gcli2api</a></p>
|
| 1812 |
+
<p style="margin: 4px 0; font-size: 12px; color: #dc3545; font-weight: 500;">⚠️ 禁止商业用途和倒卖 - 仅供学习使用 ⚠️
|
| 1813 |
+
</p>
|
| 1814 |
+
</div>
|
| 1815 |
+
</div>
|
| 1816 |
+
</div>
|
| 1817 |
+
|
| 1818 |
+
<!-- 引入公共JavaScript模块 -->
|
| 1819 |
+
<script src="./front/common.js"></script>
|
| 1820 |
+
</body>
|
| 1821 |
+
|
| 1822 |
+
</html>
|
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,179 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
日志模块 - 使用环境变量配置
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import os
|
| 6 |
+
import sys
|
| 7 |
+
import threading
|
| 8 |
+
from datetime import datetime
|
| 9 |
+
|
| 10 |
+
# 日志级别定义
|
| 11 |
+
LOG_LEVELS = {"debug": 0, "info": 1, "warning": 2, "error": 3, "critical": 4}
|
| 12 |
+
|
| 13 |
+
# 线程锁,用于文件写入同步
|
| 14 |
+
_file_lock = threading.Lock()
|
| 15 |
+
|
| 16 |
+
# 文件写入状态标志
|
| 17 |
+
_file_writing_disabled = False
|
| 18 |
+
_disable_reason = None
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def _get_current_log_level():
|
| 22 |
+
"""获取当前日志级别"""
|
| 23 |
+
level = os.getenv("LOG_LEVEL", "info").lower()
|
| 24 |
+
return LOG_LEVELS.get(level, LOG_LEVELS["info"])
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def _get_log_file_path():
|
| 28 |
+
"""获取日志文件路径"""
|
| 29 |
+
return os.getenv("LOG_FILE", "log.txt")
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def _clear_log_file():
|
| 33 |
+
"""清空日志文件(在启动时调用)"""
|
| 34 |
+
global _file_writing_disabled, _disable_reason
|
| 35 |
+
|
| 36 |
+
try:
|
| 37 |
+
log_file = _get_log_file_path()
|
| 38 |
+
with _file_lock:
|
| 39 |
+
with open(log_file, "w", encoding="utf-8") as f:
|
| 40 |
+
f.write("") # 清空文件
|
| 41 |
+
except (PermissionError, OSError, IOError) as e:
|
| 42 |
+
# 检测只读文件系统或权限问题,禁用文件写入
|
| 43 |
+
_file_writing_disabled = True
|
| 44 |
+
_disable_reason = str(e)
|
| 45 |
+
print(
|
| 46 |
+
f"Warning: File system appears to be read-only or permission denied. "
|
| 47 |
+
f"Disabling log file writing: {e}",
|
| 48 |
+
file=sys.stderr,
|
| 49 |
+
)
|
| 50 |
+
print("Log messages will continue to display in console only.", file=sys.stderr)
|
| 51 |
+
except Exception as e:
|
| 52 |
+
# 其他异常仍然输出警告但不禁用写入(可能是临时问题)
|
| 53 |
+
print(f"Warning: Failed to clear log file: {e}", file=sys.stderr)
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def _write_to_file(message: str):
|
| 57 |
+
"""线程安全地写入日志文件"""
|
| 58 |
+
global _file_writing_disabled, _disable_reason
|
| 59 |
+
|
| 60 |
+
# 如果文件写入已被禁用,直接返回
|
| 61 |
+
if _file_writing_disabled:
|
| 62 |
+
return
|
| 63 |
+
|
| 64 |
+
try:
|
| 65 |
+
log_file = _get_log_file_path()
|
| 66 |
+
with _file_lock:
|
| 67 |
+
with open(log_file, "a", encoding="utf-8") as f:
|
| 68 |
+
f.write(message + "\n")
|
| 69 |
+
f.flush() # 强制刷新到磁盘,确保实时写入
|
| 70 |
+
except (PermissionError, OSError, IOError) as e:
|
| 71 |
+
# 检测只读文件系统或权限问题,禁用文件写入
|
| 72 |
+
_file_writing_disabled = True
|
| 73 |
+
_disable_reason = str(e)
|
| 74 |
+
print(
|
| 75 |
+
f"Warning: File system appears to be read-only or permission denied. "
|
| 76 |
+
f"Disabling log file writing: {e}",
|
| 77 |
+
file=sys.stderr,
|
| 78 |
+
)
|
| 79 |
+
print("Log messages will continue to display in console only.", file=sys.stderr)
|
| 80 |
+
except Exception as e:
|
| 81 |
+
# 其他异常仍然输出警告但不禁用写入(可能是临时问题)
|
| 82 |
+
print(f"Warning: Failed to write to log file: {e}", file=sys.stderr)
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def _log(level: str, message: str):
|
| 86 |
+
"""
|
| 87 |
+
内部日志函数
|
| 88 |
+
"""
|
| 89 |
+
level = level.lower()
|
| 90 |
+
if level not in LOG_LEVELS:
|
| 91 |
+
print(f"Warning: Unknown log level '{level}'", file=sys.stderr)
|
| 92 |
+
return
|
| 93 |
+
|
| 94 |
+
# 检查日志级别
|
| 95 |
+
current_level = _get_current_log_level()
|
| 96 |
+
if LOG_LEVELS[level] < current_level:
|
| 97 |
+
return
|
| 98 |
+
|
| 99 |
+
# 截断日志消息到最多500个字符
|
| 100 |
+
#if len(message) > 500:
|
| 101 |
+
#message = message[:500] + "..."
|
| 102 |
+
|
| 103 |
+
# 格式化日志消息
|
| 104 |
+
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
| 105 |
+
entry = f"[{timestamp}] [{level.upper()}] {message}"
|
| 106 |
+
|
| 107 |
+
# 输出到控制台
|
| 108 |
+
if level in ("error", "critical"):
|
| 109 |
+
print(entry, file=sys.stderr)
|
| 110 |
+
else:
|
| 111 |
+
print(entry)
|
| 112 |
+
|
| 113 |
+
# 实时写入文件
|
| 114 |
+
_write_to_file(entry)
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def set_log_level(level: str):
|
| 118 |
+
"""设置日志级别提示"""
|
| 119 |
+
level = level.lower()
|
| 120 |
+
if level not in LOG_LEVELS:
|
| 121 |
+
print(f"Warning: Unknown log level '{level}'. Valid levels: {', '.join(LOG_LEVELS.keys())}")
|
| 122 |
+
return False
|
| 123 |
+
|
| 124 |
+
print(f"Note: To set log level '{level}', please set LOG_LEVEL environment variable")
|
| 125 |
+
return True
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
class Logger:
|
| 129 |
+
"""支持 log('info', 'msg') 和 log.info('msg') 两种调用方式"""
|
| 130 |
+
|
| 131 |
+
def __call__(self, level: str, message: str):
|
| 132 |
+
"""支持 log('info', 'message') 调用方式"""
|
| 133 |
+
_log(level, message)
|
| 134 |
+
|
| 135 |
+
def debug(self, message: str):
|
| 136 |
+
"""记录调试信息"""
|
| 137 |
+
_log("debug", message)
|
| 138 |
+
|
| 139 |
+
def info(self, message: str):
|
| 140 |
+
"""记录一般信息"""
|
| 141 |
+
_log("info", message)
|
| 142 |
+
|
| 143 |
+
def warning(self, message: str):
|
| 144 |
+
"""记录警告信息"""
|
| 145 |
+
_log("warning", message)
|
| 146 |
+
|
| 147 |
+
def error(self, message: str):
|
| 148 |
+
"""记录错误信息"""
|
| 149 |
+
_log("error", message)
|
| 150 |
+
|
| 151 |
+
def critical(self, message: str):
|
| 152 |
+
"""记录严重错误信息"""
|
| 153 |
+
_log("critical", message)
|
| 154 |
+
|
| 155 |
+
def get_current_level(self) -> str:
|
| 156 |
+
"""获取当前日志级别名称"""
|
| 157 |
+
current_level = _get_current_log_level()
|
| 158 |
+
for name, value in LOG_LEVELS.items():
|
| 159 |
+
if value == current_level:
|
| 160 |
+
return name
|
| 161 |
+
return "info"
|
| 162 |
+
|
| 163 |
+
def get_log_file(self) -> str:
|
| 164 |
+
"""获取当前日志文件路径"""
|
| 165 |
+
return _get_log_file_path()
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
# 导出全局日志实例
|
| 169 |
+
log = Logger()
|
| 170 |
+
|
| 171 |
+
# 导出的公共接口
|
| 172 |
+
__all__ = ["log", "set_log_level", "LOG_LEVELS"]
|
| 173 |
+
|
| 174 |
+
# 在模块加载时清空日志文件
|
| 175 |
+
_clear_log_file()
|
| 176 |
+
|
| 177 |
+
# 使用说明:
|
| 178 |
+
# 1. 设置日志级别: export LOG_LEVEL=debug (或在.env文件中设置)
|
| 179 |
+
# 2. 设置日志文件: export LOG_FILE=log.txt (或在.env文件中设置)
|
pyproject.toml
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.12",
|
| 18 |
+
"Programming Language :: Python :: 3.13",
|
| 19 |
+
]
|
| 20 |
+
dependencies = [
|
| 21 |
+
"aiofiles>=24.1.0",
|
| 22 |
+
"fastapi>=0.116.1",
|
| 23 |
+
"httpx[socks]>=0.28.1",
|
| 24 |
+
"hypercorn>=0.17.3",
|
| 25 |
+
"motor>=3.7.1",
|
| 26 |
+
"oauthlib>=3.3.1",
|
| 27 |
+
"pydantic>=2.11.7",
|
| 28 |
+
"pyjwt>=2.10.1",
|
| 29 |
+
"python-dotenv>=1.1.1",
|
| 30 |
+
"python-multipart>=0.0.20",
|
| 31 |
+
"pypinyin>=0.51.0",
|
| 32 |
+
"aiosqlite>=0.20.0",
|
| 33 |
+
]
|
| 34 |
+
|
| 35 |
+
[project.optional-dependencies]
|
| 36 |
+
dev = [
|
| 37 |
+
"pytest>=8.0.0",
|
| 38 |
+
"pytest-asyncio>=0.23.0",
|
| 39 |
+
"pytest-cov>=4.1.0",
|
| 40 |
+
"black>=24.0.0",
|
| 41 |
+
"flake8>=7.0.0",
|
| 42 |
+
"mypy>=1.8.0",
|
| 43 |
+
"pre-commit>=3.6.0",
|
| 44 |
+
]
|
| 45 |
+
|
| 46 |
+
[tool.pytest.ini_options]
|
| 47 |
+
minversion = "8.0"
|
| 48 |
+
testpaths = ["."]
|
| 49 |
+
python_files = ["test_*.py"]
|
| 50 |
+
python_classes = ["Test*"]
|
| 51 |
+
python_functions = ["test_*"]
|
| 52 |
+
asyncio_mode = "auto"
|
| 53 |
+
addopts = [
|
| 54 |
+
"-v",
|
| 55 |
+
"--strict-markers",
|
| 56 |
+
]
|
| 57 |
+
|
| 58 |
+
[tool.black]
|
| 59 |
+
line-length = 100
|
| 60 |
+
target-version = ["py312"]
|
| 61 |
+
include = '\.pyi?$'
|
| 62 |
+
extend-exclude = '''
|
| 63 |
+
/(
|
| 64 |
+
# directories
|
| 65 |
+
\.eggs
|
| 66 |
+
| \.git
|
| 67 |
+
| \.hg
|
| 68 |
+
| \.mypy_cache
|
| 69 |
+
| \.tox
|
| 70 |
+
| \.venv
|
| 71 |
+
| build
|
| 72 |
+
| dist
|
| 73 |
+
)/
|
| 74 |
+
'''
|
| 75 |
+
|
| 76 |
+
[tool.mypy]
|
| 77 |
+
python_version = "3.12"
|
| 78 |
+
warn_return_any = true
|
| 79 |
+
warn_unused_configs = true
|
| 80 |
+
disallow_untyped_defs = false
|
| 81 |
+
ignore_missing_imports = true
|
| 82 |
+
exclude = [
|
| 83 |
+
"build",
|
| 84 |
+
"dist",
|
| 85 |
+
]
|
| 86 |
+
|
| 87 |
+
[tool.coverage.run]
|
| 88 |
+
source = ["src"]
|
| 89 |
+
omit = [
|
| 90 |
+
"*/tests/*",
|
| 91 |
+
"*/test_*.py",
|
| 92 |
+
]
|
| 93 |
+
|
| 94 |
+
[tool.coverage.report]
|
| 95 |
+
exclude_lines = [
|
| 96 |
+
"pragma: no cover",
|
| 97 |
+
"def __repr__",
|
| 98 |
+
"raise AssertionError",
|
| 99 |
+
"raise NotImplementedError",
|
| 100 |
+
"if __name__ == .__main__.:",
|
| 101 |
+
"if TYPE_CHECKING:",
|
| 102 |
+
]
|
requirements-dev.txt
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Development dependencies for gcli2api
|
| 2 |
+
# Install with: pip install -r requirements-dev.txt
|
| 3 |
+
|
| 4 |
+
# Testing
|
| 5 |
+
pytest>=8.0.0
|
| 6 |
+
pytest-asyncio>=0.23.0
|
| 7 |
+
pytest-cov>=4.1.0
|
| 8 |
+
|
| 9 |
+
# Code formatting and linting
|
| 10 |
+
black>=24.0.0
|
| 11 |
+
flake8>=7.0.0
|
| 12 |
+
isort>=5.13.0
|
| 13 |
+
mypy>=1.8.0
|
| 14 |
+
|
| 15 |
+
# Pre-commit hooks
|
| 16 |
+
pre-commit>=3.6.0
|
| 17 |
+
|
| 18 |
+
# Security scanning
|
| 19 |
+
safety>=3.0.0
|
| 20 |
+
bandit>=1.7.5
|
requirements-termux.txt
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
requirements.txt
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
runtime.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
python-3.12.7
|
setup-dev.sh
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/bin/bash
|
| 2 |
+
# Development setup script for gcli2api
|
| 3 |
+
# This script sets up the development environment
|
| 4 |
+
|
| 5 |
+
set -e
|
| 6 |
+
|
| 7 |
+
echo "=========================================="
|
| 8 |
+
echo "gcli2api Development Setup"
|
| 9 |
+
echo "=========================================="
|
| 10 |
+
echo
|
| 11 |
+
|
| 12 |
+
# Check Python version
|
| 13 |
+
echo "Checking Python version..."
|
| 14 |
+
python_version=$(python --version 2>&1 | awk '{print $2}')
|
| 15 |
+
required_version="3.12"
|
| 16 |
+
|
| 17 |
+
if ! python -c "import sys; exit(0 if sys.version_info >= (3, 12) else 1)"; then
|
| 18 |
+
echo "❌ Error: Python 3.12 or higher is required. Found: $python_version"
|
| 19 |
+
exit 1
|
| 20 |
+
fi
|
| 21 |
+
echo "✅ Python $python_version"
|
| 22 |
+
echo
|
| 23 |
+
|
| 24 |
+
# Create virtual environment if it doesn't exist
|
| 25 |
+
if [ ! -d "venv" ]; then
|
| 26 |
+
echo "Creating virtual environment..."
|
| 27 |
+
python -m venv venv
|
| 28 |
+
echo "✅ Virtual environment created"
|
| 29 |
+
else
|
| 30 |
+
echo "✅ Virtual environment already exists"
|
| 31 |
+
fi
|
| 32 |
+
echo
|
| 33 |
+
|
| 34 |
+
# Activate virtual environment
|
| 35 |
+
echo "Activating virtual environment..."
|
| 36 |
+
source venv/bin/activate
|
| 37 |
+
echo "✅ Virtual environment activated"
|
| 38 |
+
echo
|
| 39 |
+
|
| 40 |
+
# Upgrade pip
|
| 41 |
+
echo "Upgrading pip..."
|
| 42 |
+
pip install --upgrade pip -q
|
| 43 |
+
echo "✅ pip upgraded"
|
| 44 |
+
echo
|
| 45 |
+
|
| 46 |
+
# Install production dependencies
|
| 47 |
+
echo "Installing production dependencies..."
|
| 48 |
+
pip install -r requirements.txt -q
|
| 49 |
+
echo "✅ Production dependencies installed"
|
| 50 |
+
echo
|
| 51 |
+
|
| 52 |
+
# Install development dependencies
|
| 53 |
+
echo "Installing development dependencies..."
|
| 54 |
+
pip install -r requirements-dev.txt -q
|
| 55 |
+
echo "✅ Development dependencies installed"
|
| 56 |
+
echo
|
| 57 |
+
|
| 58 |
+
# Copy .env.example to .env if it doesn't exist
|
| 59 |
+
if [ ! -f ".env" ]; then
|
| 60 |
+
echo "Creating .env file from .env.example..."
|
| 61 |
+
cp .env.example .env
|
| 62 |
+
echo "✅ .env file created"
|
| 63 |
+
echo "⚠️ Please edit .env file with your configuration"
|
| 64 |
+
else
|
| 65 |
+
echo "✅ .env file already exists"
|
| 66 |
+
fi
|
| 67 |
+
echo
|
| 68 |
+
|
| 69 |
+
# Install pre-commit hooks
|
| 70 |
+
echo "Installing pre-commit hooks..."
|
| 71 |
+
pre-commit install
|
| 72 |
+
echo "✅ Pre-commit hooks installed"
|
| 73 |
+
echo
|
| 74 |
+
|
| 75 |
+
echo "=========================================="
|
| 76 |
+
echo "✅ Development setup complete!"
|
| 77 |
+
echo "=========================================="
|
| 78 |
+
echo
|
| 79 |
+
echo "Next steps:"
|
| 80 |
+
echo " 1. Edit .env with your configuration"
|
| 81 |
+
echo " 2. Run 'make test' to verify setup"
|
| 82 |
+
echo " 3. Run 'make run' to start the application"
|
| 83 |
+
echo
|
| 84 |
+
echo "Available commands:"
|
| 85 |
+
echo " make help - Show all available commands"
|
| 86 |
+
echo " make test - Run tests"
|
| 87 |
+
echo " make lint - Run linters"
|
| 88 |
+
echo " make format - Format code"
|
| 89 |
+
echo " make run - Run the application"
|
| 90 |
+
echo
|
| 91 |
+
echo "To activate the virtual environment in the future:"
|
| 92 |
+
echo " source venv/bin/activate"
|
| 93 |
+
echo
|
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,694 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 11 |
+
|
| 12 |
+
from fastapi import Response
|
| 13 |
+
from config import (
|
| 14 |
+
get_antigravity_api_url,
|
| 15 |
+
get_antigravity_stream2nostream,
|
| 16 |
+
get_auto_ban_error_codes,
|
| 17 |
+
)
|
| 18 |
+
from log import log
|
| 19 |
+
|
| 20 |
+
from src.credential_manager import CredentialManager
|
| 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 |
+
# 全局凭证管理器实例(单例模式)
|
| 38 |
+
_credential_manager: Optional[CredentialManager] = None
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
async def _get_credential_manager() -> CredentialManager:
|
| 42 |
+
"""
|
| 43 |
+
获取全局凭证管理器实例
|
| 44 |
+
|
| 45 |
+
Returns:
|
| 46 |
+
CredentialManager实例
|
| 47 |
+
"""
|
| 48 |
+
global _credential_manager
|
| 49 |
+
if not _credential_manager:
|
| 50 |
+
_credential_manager = CredentialManager()
|
| 51 |
+
await _credential_manager.initialize()
|
| 52 |
+
return _credential_manager
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
# ==================== 辅助函数 ====================
|
| 56 |
+
|
| 57 |
+
def build_antigravity_headers(access_token: str, model_name: str = "") -> Dict[str, str]:
|
| 58 |
+
"""
|
| 59 |
+
构建 Antigravity API 请求头
|
| 60 |
+
|
| 61 |
+
Args:
|
| 62 |
+
access_token: 访问令牌
|
| 63 |
+
model_name: 模型名称,用于判断 request_type
|
| 64 |
+
|
| 65 |
+
Returns:
|
| 66 |
+
请求头字典
|
| 67 |
+
"""
|
| 68 |
+
headers = {
|
| 69 |
+
'User-Agent': ANTIGRAVITY_USER_AGENT,
|
| 70 |
+
'Authorization': f'Bearer {access_token}',
|
| 71 |
+
'Content-Type': 'application/json',
|
| 72 |
+
'Accept-Encoding': 'gzip',
|
| 73 |
+
'requestId': f"req-{uuid.uuid4()}"
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
# 根据模型名称判断 request_type
|
| 77 |
+
if model_name:
|
| 78 |
+
request_type = "image_gen" if "image" in model_name.lower() else "agent"
|
| 79 |
+
headers['requestType'] = request_type
|
| 80 |
+
|
| 81 |
+
return headers
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
# ==================== 新的流式和非流式请求函数 ====================
|
| 85 |
+
|
| 86 |
+
async def stream_request(
|
| 87 |
+
body: Dict[str, Any],
|
| 88 |
+
native: bool = False,
|
| 89 |
+
headers: Optional[Dict[str, str]] = None,
|
| 90 |
+
):
|
| 91 |
+
"""
|
| 92 |
+
流式请求函数
|
| 93 |
+
|
| 94 |
+
Args:
|
| 95 |
+
body: 请求体
|
| 96 |
+
native: 是否返回原生bytes流,False则返回str流
|
| 97 |
+
headers: 额外的请求头
|
| 98 |
+
|
| 99 |
+
Yields:
|
| 100 |
+
Response对象(错误时)或 bytes流/str流(成功时)
|
| 101 |
+
"""
|
| 102 |
+
# 获取凭证管理器
|
| 103 |
+
credential_manager = await _get_credential_manager()
|
| 104 |
+
|
| 105 |
+
model_name = body.get("model", "")
|
| 106 |
+
|
| 107 |
+
# 1. 获取有效凭证
|
| 108 |
+
cred_result = await credential_manager.get_valid_credential(
|
| 109 |
+
mode="antigravity", model_key=model_name
|
| 110 |
+
)
|
| 111 |
+
|
| 112 |
+
if not cred_result:
|
| 113 |
+
# 如果返回值是None,直接返回错误500
|
| 114 |
+
log.error("[ANTIGRAVITY STREAM] 当前无可用凭证")
|
| 115 |
+
yield Response(
|
| 116 |
+
content=json.dumps({"error": "当前无可用凭证"}),
|
| 117 |
+
status_code=500,
|
| 118 |
+
media_type="application/json"
|
| 119 |
+
)
|
| 120 |
+
return
|
| 121 |
+
|
| 122 |
+
current_file, credential_data = cred_result
|
| 123 |
+
access_token = credential_data.get("access_token") or credential_data.get("token")
|
| 124 |
+
|
| 125 |
+
if not access_token:
|
| 126 |
+
log.error(f"[ANTIGRAVITY STREAM] No access token in credential: {current_file}")
|
| 127 |
+
yield Response(
|
| 128 |
+
content=json.dumps({"error": "凭证中没有访问令牌"}),
|
| 129 |
+
status_code=500,
|
| 130 |
+
media_type="application/json"
|
| 131 |
+
)
|
| 132 |
+
return
|
| 133 |
+
|
| 134 |
+
# 2. 构建URL和请求头
|
| 135 |
+
antigravity_url = await get_antigravity_api_url()
|
| 136 |
+
target_url = f"{antigravity_url}/v1internal:streamGenerateContent?alt=sse"
|
| 137 |
+
|
| 138 |
+
auth_headers = build_antigravity_headers(access_token, model_name)
|
| 139 |
+
|
| 140 |
+
# 合并自定义headers
|
| 141 |
+
if headers:
|
| 142 |
+
auth_headers.update(headers)
|
| 143 |
+
|
| 144 |
+
# 3. 调用stream_post_async进行请求
|
| 145 |
+
retry_config = await get_retry_config()
|
| 146 |
+
max_retries = retry_config["max_retries"]
|
| 147 |
+
retry_interval = retry_config["retry_interval"]
|
| 148 |
+
|
| 149 |
+
DISABLE_ERROR_CODES = await get_auto_ban_error_codes() # 禁用凭证的错误码
|
| 150 |
+
last_error_response = None # 记录最后一次的错误响应
|
| 151 |
+
|
| 152 |
+
# 内部函数:获取新凭证并更新headers
|
| 153 |
+
async def refresh_credential():
|
| 154 |
+
nonlocal current_file, access_token, auth_headers
|
| 155 |
+
cred_result = await credential_manager.get_valid_credential(
|
| 156 |
+
mode="antigravity", model_key=model_name
|
| 157 |
+
)
|
| 158 |
+
if not cred_result:
|
| 159 |
+
return None
|
| 160 |
+
current_file, credential_data = cred_result
|
| 161 |
+
access_token = credential_data.get("access_token") or credential_data.get("token")
|
| 162 |
+
if not access_token:
|
| 163 |
+
return None
|
| 164 |
+
auth_headers = build_antigravity_headers(access_token, model_name)
|
| 165 |
+
if headers:
|
| 166 |
+
auth_headers.update(headers)
|
| 167 |
+
return True
|
| 168 |
+
|
| 169 |
+
for attempt in range(max_retries + 1):
|
| 170 |
+
success_recorded = False # 标记是否已记录成功
|
| 171 |
+
need_retry = False # 标记是否需要重试
|
| 172 |
+
|
| 173 |
+
try:
|
| 174 |
+
async for chunk in stream_post_async(
|
| 175 |
+
url=target_url,
|
| 176 |
+
body=body,
|
| 177 |
+
native=native,
|
| 178 |
+
headers=auth_headers
|
| 179 |
+
):
|
| 180 |
+
# 判断是否是Response对象
|
| 181 |
+
if isinstance(chunk, Response):
|
| 182 |
+
status_code = chunk.status_code
|
| 183 |
+
last_error_response = chunk # 记录最后一次错误
|
| 184 |
+
|
| 185 |
+
# 如果错误码是429或者不在禁用码当中,做好记录后进行重试
|
| 186 |
+
if status_code == 429 or status_code not in DISABLE_ERROR_CODES:
|
| 187 |
+
# 解析错误响应内容
|
| 188 |
+
try:
|
| 189 |
+
error_body = chunk.body.decode('utf-8') if isinstance(chunk.body, bytes) else str(chunk.body)
|
| 190 |
+
log.warning(f"[ANTIGRAVITY STREAM] 流式请求失败 (status={status_code}), 凭证: {current_file}, 响应: {error_body[:500]}")
|
| 191 |
+
except Exception:
|
| 192 |
+
log.warning(f"[ANTIGRAVITY STREAM] 流式请求失败 (status={status_code}), 凭证: {current_file}")
|
| 193 |
+
|
| 194 |
+
# 记录错误
|
| 195 |
+
cooldown_until = None
|
| 196 |
+
if status_code == 429:
|
| 197 |
+
# 尝试解析冷却时间
|
| 198 |
+
try:
|
| 199 |
+
error_body = chunk.body.decode('utf-8') if isinstance(chunk.body, bytes) else str(chunk.body)
|
| 200 |
+
cooldown_until = await parse_and_log_cooldown(error_body, mode="antigravity")
|
| 201 |
+
except Exception:
|
| 202 |
+
pass
|
| 203 |
+
|
| 204 |
+
await record_api_call_error(
|
| 205 |
+
credential_manager, current_file, status_code,
|
| 206 |
+
cooldown_until, mode="antigravity", model_key=model_name
|
| 207 |
+
)
|
| 208 |
+
|
| 209 |
+
# 检查是否应该重试
|
| 210 |
+
should_retry = await handle_error_with_retry(
|
| 211 |
+
credential_manager, status_code, current_file,
|
| 212 |
+
retry_config["retry_enabled"], attempt, max_retries, retry_interval,
|
| 213 |
+
mode="antigravity"
|
| 214 |
+
)
|
| 215 |
+
|
| 216 |
+
if should_retry and attempt < max_retries:
|
| 217 |
+
need_retry = True
|
| 218 |
+
break # 跳出内层循环,准备重试
|
| 219 |
+
else:
|
| 220 |
+
# 不重试,直接返回原始错误
|
| 221 |
+
log.error(f"[ANTIGRAVITY STREAM] 达到最大重试次数或不应重试,返回原始错误")
|
| 222 |
+
yield chunk
|
| 223 |
+
return
|
| 224 |
+
else:
|
| 225 |
+
# 错误码在禁用码当中,直接返回,无需重试
|
| 226 |
+
try:
|
| 227 |
+
error_body = chunk.body.decode('utf-8') if isinstance(chunk.body, bytes) else str(chunk.body)
|
| 228 |
+
log.error(f"[ANTIGRAVITY STREAM] 流式请求失败,禁用错误码 (status={status_code}), 凭证: {current_file}, 响应: {error_body[:500]}")
|
| 229 |
+
except Exception:
|
| 230 |
+
log.error(f"[ANTIGRAVITY STREAM] 流式请求失败,禁用错误码 (status={status_code}), 凭证: {current_file}")
|
| 231 |
+
await record_api_call_error(
|
| 232 |
+
credential_manager, current_file, status_code,
|
| 233 |
+
None, mode="antigravity", model_key=model_name
|
| 234 |
+
)
|
| 235 |
+
yield chunk
|
| 236 |
+
return
|
| 237 |
+
else:
|
| 238 |
+
# 不是Response,说明是真流,直接yield返回
|
| 239 |
+
# 只在第一个chunk时记录成功
|
| 240 |
+
if not success_recorded:
|
| 241 |
+
await record_api_call_success(
|
| 242 |
+
credential_manager, current_file, mode="antigravity", model_key=model_name
|
| 243 |
+
)
|
| 244 |
+
success_recorded = True
|
| 245 |
+
log.info(f"[ANTIGRAVITY STREAM] 开始接收流式响应,模型: {model_name}")
|
| 246 |
+
|
| 247 |
+
# 记录原始chunk内容(用于调试)
|
| 248 |
+
if isinstance(chunk, bytes):
|
| 249 |
+
log.debug(f"[ANTIGRAVITY STREAM RAW] chunk(bytes): {chunk}")
|
| 250 |
+
else:
|
| 251 |
+
log.debug(f"[ANTIGRAVITY STREAM RAW] chunk(str): {chunk}")
|
| 252 |
+
|
| 253 |
+
yield chunk
|
| 254 |
+
|
| 255 |
+
# 流式请求完成,检查结果
|
| 256 |
+
if success_recorded:
|
| 257 |
+
log.info(f"[ANTIGRAVITY STREAM] 流式响应完成,模型: {model_name}")
|
| 258 |
+
return
|
| 259 |
+
elif not need_retry:
|
| 260 |
+
# 没有收到任何数据(空回复),需要重试
|
| 261 |
+
log.warning(f"[ANTIGRAVITY STREAM] 收到空回复,无任何内容,凭证: {current_file}")
|
| 262 |
+
await record_api_call_error(
|
| 263 |
+
credential_manager, current_file, 200,
|
| 264 |
+
None, mode="antigravity", model_key=model_name
|
| 265 |
+
)
|
| 266 |
+
|
| 267 |
+
if attempt < max_retries:
|
| 268 |
+
need_retry = True
|
| 269 |
+
else:
|
| 270 |
+
log.error(f"[ANTIGRAVITY STREAM] 空回复达到最大重试次数")
|
| 271 |
+
yield Response(
|
| 272 |
+
content=json.dumps({"error": "服务返回空回复"}),
|
| 273 |
+
status_code=500,
|
| 274 |
+
media_type="application/json"
|
| 275 |
+
)
|
| 276 |
+
return
|
| 277 |
+
|
| 278 |
+
# 统一处理重试
|
| 279 |
+
if need_retry:
|
| 280 |
+
log.info(f"[ANTIGRAVITY STREAM] 重试请求 (attempt {attempt + 2}/{max_retries + 1})...")
|
| 281 |
+
await asyncio.sleep(retry_interval)
|
| 282 |
+
|
| 283 |
+
if not await refresh_credential():
|
| 284 |
+
log.error("[ANTIGRAVITY STREAM] 重试时无可用凭证或令牌")
|
| 285 |
+
yield Response(
|
| 286 |
+
content=json.dumps({"error": "当前无可用凭证"}),
|
| 287 |
+
status_code=500,
|
| 288 |
+
media_type="application/json"
|
| 289 |
+
)
|
| 290 |
+
return
|
| 291 |
+
continue # 重试
|
| 292 |
+
|
| 293 |
+
except Exception as e:
|
| 294 |
+
log.error(f"[ANTIGRAVITY STREAM] 流式请求异常: {e}, 凭证: {current_file}")
|
| 295 |
+
if attempt < max_retries:
|
| 296 |
+
log.info(f"[ANTIGRAVITY STREAM] 异常后重试 (attempt {attempt + 2}/{max_retries + 1})...")
|
| 297 |
+
await asyncio.sleep(retry_interval)
|
| 298 |
+
continue
|
| 299 |
+
else:
|
| 300 |
+
# 所有重试都失败,返回最后一次的错误(如果有)
|
| 301 |
+
log.error(f"[ANTIGRAVITY STREAM] 所有重试均失败,最后异常: {e}")
|
| 302 |
+
yield last_error_response
|
| 303 |
+
|
| 304 |
+
|
| 305 |
+
async def non_stream_request(
|
| 306 |
+
body: Dict[str, Any],
|
| 307 |
+
headers: Optional[Dict[str, str]] = None,
|
| 308 |
+
) -> Response:
|
| 309 |
+
"""
|
| 310 |
+
非流式请求函数
|
| 311 |
+
|
| 312 |
+
Args:
|
| 313 |
+
body: 请求体
|
| 314 |
+
headers: 额外的请求头
|
| 315 |
+
|
| 316 |
+
Returns:
|
| 317 |
+
Response对象
|
| 318 |
+
"""
|
| 319 |
+
# 检查是否启用流式收集模式
|
| 320 |
+
if await get_antigravity_stream2nostream():
|
| 321 |
+
log.info("[ANTIGRAVITY] 使用流式收集模式实现非流式请求")
|
| 322 |
+
|
| 323 |
+
# 调用stream_request获取流
|
| 324 |
+
stream = stream_request(body=body, native=False, headers=headers)
|
| 325 |
+
|
| 326 |
+
# 收集流式响应
|
| 327 |
+
# stream_request是一个异步生成器,可能yield Response(错误)或流数据
|
| 328 |
+
# collect_streaming_response会自动处理这两种情况
|
| 329 |
+
return await collect_streaming_response(stream)
|
| 330 |
+
|
| 331 |
+
# 否则使用传统非流式模式
|
| 332 |
+
log.info("[ANTIGRAVITY] 使用传统非流式模式")
|
| 333 |
+
|
| 334 |
+
# 获取凭证管理器
|
| 335 |
+
credential_manager = await _get_credential_manager()
|
| 336 |
+
|
| 337 |
+
model_name = body.get("model", "")
|
| 338 |
+
|
| 339 |
+
# 1. 获取有效凭证
|
| 340 |
+
cred_result = await credential_manager.get_valid_credential(
|
| 341 |
+
mode="antigravity", model_key=model_name
|
| 342 |
+
)
|
| 343 |
+
|
| 344 |
+
if not cred_result:
|
| 345 |
+
# 如果返回值是None,直接返回错误500
|
| 346 |
+
log.error("[ANTIGRAVITY] 当前无可用凭证")
|
| 347 |
+
return Response(
|
| 348 |
+
content=json.dumps({"error": "当前无可用凭证"}),
|
| 349 |
+
status_code=500,
|
| 350 |
+
media_type="application/json"
|
| 351 |
+
)
|
| 352 |
+
|
| 353 |
+
current_file, credential_data = cred_result
|
| 354 |
+
access_token = credential_data.get("access_token") or credential_data.get("token")
|
| 355 |
+
|
| 356 |
+
if not access_token:
|
| 357 |
+
log.error(f"[ANTIGRAVITY] No access token in credential: {current_file}")
|
| 358 |
+
return Response(
|
| 359 |
+
content=json.dumps({"error": "凭证中没有访问令牌"}),
|
| 360 |
+
status_code=500,
|
| 361 |
+
media_type="application/json"
|
| 362 |
+
)
|
| 363 |
+
|
| 364 |
+
# 2. 构建URL和请求头
|
| 365 |
+
antigravity_url = await get_antigravity_api_url()
|
| 366 |
+
target_url = f"{antigravity_url}/v1internal:generateContent"
|
| 367 |
+
|
| 368 |
+
auth_headers = build_antigravity_headers(access_token, model_name)
|
| 369 |
+
|
| 370 |
+
# 合并自定义headers
|
| 371 |
+
if headers:
|
| 372 |
+
auth_headers.update(headers)
|
| 373 |
+
|
| 374 |
+
# 3. 调用post_async进行请求
|
| 375 |
+
retry_config = await get_retry_config()
|
| 376 |
+
max_retries = retry_config["max_retries"]
|
| 377 |
+
retry_interval = retry_config["retry_interval"]
|
| 378 |
+
|
| 379 |
+
DISABLE_ERROR_CODES = await get_auto_ban_error_codes() # 禁用凭证的错误码
|
| 380 |
+
last_error_response = None # 记录最后一次的错误响应
|
| 381 |
+
|
| 382 |
+
# 内部函数:获取新凭证并更新headers
|
| 383 |
+
async def refresh_credential():
|
| 384 |
+
nonlocal current_file, access_token, auth_headers
|
| 385 |
+
cred_result = await credential_manager.get_valid_credential(
|
| 386 |
+
mode="antigravity", model_key=model_name
|
| 387 |
+
)
|
| 388 |
+
if not cred_result:
|
| 389 |
+
return None
|
| 390 |
+
current_file, credential_data = cred_result
|
| 391 |
+
access_token = credential_data.get("access_token") or credential_data.get("token")
|
| 392 |
+
if not access_token:
|
| 393 |
+
return None
|
| 394 |
+
auth_headers = build_antigravity_headers(access_token, model_name)
|
| 395 |
+
if headers:
|
| 396 |
+
auth_headers.update(headers)
|
| 397 |
+
return True
|
| 398 |
+
|
| 399 |
+
for attempt in range(max_retries + 1):
|
| 400 |
+
need_retry = False # 标记是否需要重试
|
| 401 |
+
|
| 402 |
+
try:
|
| 403 |
+
response = await post_async(
|
| 404 |
+
url=target_url,
|
| 405 |
+
json=body,
|
| 406 |
+
headers=auth_headers,
|
| 407 |
+
timeout=300.0
|
| 408 |
+
)
|
| 409 |
+
|
| 410 |
+
status_code = response.status_code
|
| 411 |
+
|
| 412 |
+
# 成功
|
| 413 |
+
if status_code == 200:
|
| 414 |
+
# 检查是否为空回复
|
| 415 |
+
if not response.content or len(response.content) == 0:
|
| 416 |
+
log.warning(f"[ANTIGRAVITY] 收到200响应但内容为空,凭证: {current_file}")
|
| 417 |
+
|
| 418 |
+
# 记录错误
|
| 419 |
+
await record_api_call_error(
|
| 420 |
+
credential_manager, current_file, 200,
|
| 421 |
+
None, mode="antigravity", model_key=model_name
|
| 422 |
+
)
|
| 423 |
+
|
| 424 |
+
if attempt < max_retries:
|
| 425 |
+
need_retry = True
|
| 426 |
+
else:
|
| 427 |
+
log.error(f"[ANTIGRAVITY] 空回复达到最大重试次数")
|
| 428 |
+
return Response(
|
| 429 |
+
content=json.dumps({"error": "服务返回空回复"}),
|
| 430 |
+
status_code=500,
|
| 431 |
+
media_type="application/json"
|
| 432 |
+
)
|
| 433 |
+
else:
|
| 434 |
+
# 正常响应
|
| 435 |
+
await record_api_call_success(
|
| 436 |
+
credential_manager, current_file, mode="antigravity", model_key=model_name
|
| 437 |
+
)
|
| 438 |
+
return Response(
|
| 439 |
+
content=response.content,
|
| 440 |
+
status_code=200,
|
| 441 |
+
headers=dict(response.headers)
|
| 442 |
+
)
|
| 443 |
+
|
| 444 |
+
# 失败 - 记录最后一次错误
|
| 445 |
+
if status_code != 200:
|
| 446 |
+
last_error_response = Response(
|
| 447 |
+
content=response.content,
|
| 448 |
+
status_code=status_code,
|
| 449 |
+
headers=dict(response.headers)
|
| 450 |
+
)
|
| 451 |
+
|
| 452 |
+
# 判断是否需要重试
|
| 453 |
+
if status_code == 429 or status_code not in DISABLE_ERROR_CODES:
|
| 454 |
+
try:
|
| 455 |
+
error_text = response.text
|
| 456 |
+
log.warning(f"[ANTIGRAVITY] 非流式请求失败 (status={status_code}), 凭证: {current_file}, 响应: {error_text[:500]}")
|
| 457 |
+
except Exception:
|
| 458 |
+
log.warning(f"[ANTIGRAVITY] 非流式请求失败 (status={status_code}), 凭证: {current_file}")
|
| 459 |
+
|
| 460 |
+
# 记录错误
|
| 461 |
+
cooldown_until = None
|
| 462 |
+
if status_code == 429:
|
| 463 |
+
# 尝试解析冷却时间
|
| 464 |
+
try:
|
| 465 |
+
error_text = response.text
|
| 466 |
+
cooldown_until = await parse_and_log_cooldown(error_text, mode="antigravity")
|
| 467 |
+
except Exception:
|
| 468 |
+
pass
|
| 469 |
+
|
| 470 |
+
await record_api_call_error(
|
| 471 |
+
credential_manager, current_file, status_code,
|
| 472 |
+
cooldown_until, mode="antigravity", model_key=model_name
|
| 473 |
+
)
|
| 474 |
+
|
| 475 |
+
# 检查是否应该重试
|
| 476 |
+
should_retry = await handle_error_with_retry(
|
| 477 |
+
credential_manager, status_code, current_file,
|
| 478 |
+
retry_config["retry_enabled"], attempt, max_retries, retry_interval,
|
| 479 |
+
mode="antigravity"
|
| 480 |
+
)
|
| 481 |
+
|
| 482 |
+
if should_retry and attempt < max_retries:
|
| 483 |
+
need_retry = True
|
| 484 |
+
else:
|
| 485 |
+
# 不重试,直接返回原始错误
|
| 486 |
+
log.error(f"[ANTIGRAVITY] 达到最大重试次数或不应重试,返回原始错误")
|
| 487 |
+
return last_error_response
|
| 488 |
+
else:
|
| 489 |
+
# 错误码在禁用码当中,直接返回,无需重试
|
| 490 |
+
try:
|
| 491 |
+
error_text = response.text
|
| 492 |
+
log.error(f"[ANTIGRAVITY] 非流式请求失败,禁用错误码 (status={status_code}), 凭证: {current_file}, 响应: {error_text[:500]}")
|
| 493 |
+
except Exception:
|
| 494 |
+
log.error(f"[ANTIGRAVITY] 非流式请求失败,禁用错误码 (status={status_code}), 凭证: {current_file}")
|
| 495 |
+
await record_api_call_error(
|
| 496 |
+
credential_manager, current_file, status_code,
|
| 497 |
+
None, mode="antigravity", model_key=model_name
|
| 498 |
+
)
|
| 499 |
+
return last_error_response
|
| 500 |
+
|
| 501 |
+
# 统一处理重试
|
| 502 |
+
if need_retry:
|
| 503 |
+
log.info(f"[ANTIGRAVITY] 重试请求 (attempt {attempt + 2}/{max_retries + 1})...")
|
| 504 |
+
await asyncio.sleep(retry_interval)
|
| 505 |
+
|
| 506 |
+
if not await refresh_credential():
|
| 507 |
+
log.error("[ANTIGRAVITY] 重试时无可用凭证或令牌")
|
| 508 |
+
return Response(
|
| 509 |
+
content=json.dumps({"error": "当前无可用凭证"}),
|
| 510 |
+
status_code=500,
|
| 511 |
+
media_type="application/json"
|
| 512 |
+
)
|
| 513 |
+
continue # 重试
|
| 514 |
+
|
| 515 |
+
except Exception as e:
|
| 516 |
+
log.error(f"[ANTIGRAVITY] 非流式请求异常: {e}, 凭证: {current_file}")
|
| 517 |
+
if attempt < max_retries:
|
| 518 |
+
log.info(f"[ANTIGRAVITY] 异常后重试 (attempt {attempt + 2}/{max_retries + 1})...")
|
| 519 |
+
await asyncio.sleep(retry_interval)
|
| 520 |
+
continue
|
| 521 |
+
else:
|
| 522 |
+
# 所有重试都失败,返回最后一次的错误(如果有)
|
| 523 |
+
log.error(f"[ANTIGRAVITY] 所有重试均失败,最后异常: {e}")
|
| 524 |
+
return last_error_response
|
| 525 |
+
|
| 526 |
+
# 所有重试都失败,返回最后一次的原始错误
|
| 527 |
+
log.error("[ANTIGRAVITY] 所有重试均失败")
|
| 528 |
+
return last_error_response
|
| 529 |
+
|
| 530 |
+
|
| 531 |
+
# ==================== 模型和配额查询 ====================
|
| 532 |
+
|
| 533 |
+
async def fetch_available_models() -> List[Dict[str, Any]]:
|
| 534 |
+
"""
|
| 535 |
+
获取可用模型列表,返回符合 OpenAI API 规范的格式
|
| 536 |
+
|
| 537 |
+
Returns:
|
| 538 |
+
模型列表,格式为字典列表(用于兼容现有代码)
|
| 539 |
+
|
| 540 |
+
Raises:
|
| 541 |
+
返回空列表如果获取失败
|
| 542 |
+
"""
|
| 543 |
+
# 获取凭证管理器和可用凭证
|
| 544 |
+
credential_manager = await _get_credential_manager()
|
| 545 |
+
cred_result = await credential_manager.get_valid_credential(mode="antigravity")
|
| 546 |
+
if not cred_result:
|
| 547 |
+
log.error("[ANTIGRAVITY] No valid credentials available for fetching models")
|
| 548 |
+
return []
|
| 549 |
+
|
| 550 |
+
current_file, credential_data = cred_result
|
| 551 |
+
access_token = credential_data.get("access_token") or credential_data.get("token")
|
| 552 |
+
|
| 553 |
+
if not access_token:
|
| 554 |
+
log.error(f"[ANTIGRAVITY] No access token in credential: {current_file}")
|
| 555 |
+
return []
|
| 556 |
+
|
| 557 |
+
# 构建请求头
|
| 558 |
+
headers = build_antigravity_headers(access_token)
|
| 559 |
+
|
| 560 |
+
try:
|
| 561 |
+
# 使用 POST 请求获取模型列表
|
| 562 |
+
antigravity_url = await get_antigravity_api_url()
|
| 563 |
+
|
| 564 |
+
response = await post_async(
|
| 565 |
+
url=f"{antigravity_url}/v1internal:fetchAvailableModels",
|
| 566 |
+
json={}, # 空的请求体
|
| 567 |
+
headers=headers
|
| 568 |
+
)
|
| 569 |
+
|
| 570 |
+
if response.status_code == 200:
|
| 571 |
+
data = response.json()
|
| 572 |
+
log.debug(f"[ANTIGRAVITY] Raw models response: {json.dumps(data, ensure_ascii=False)[:500]}")
|
| 573 |
+
|
| 574 |
+
# 转换为 OpenAI 格式的模型列表,使用 Model 类
|
| 575 |
+
model_list = []
|
| 576 |
+
current_timestamp = int(datetime.now(timezone.utc).timestamp())
|
| 577 |
+
|
| 578 |
+
if 'models' in data and isinstance(data['models'], dict):
|
| 579 |
+
# 遍历模型字典
|
| 580 |
+
for model_id in data['models'].keys():
|
| 581 |
+
model = Model(
|
| 582 |
+
id=model_id,
|
| 583 |
+
object='model',
|
| 584 |
+
created=current_timestamp,
|
| 585 |
+
owned_by='google'
|
| 586 |
+
)
|
| 587 |
+
model_list.append(model_to_dict(model))
|
| 588 |
+
|
| 589 |
+
# 添加额外的 claude-opus-4-5 模型
|
| 590 |
+
claude_opus_model = Model(
|
| 591 |
+
id='claude-opus-4-5',
|
| 592 |
+
object='model',
|
| 593 |
+
created=current_timestamp,
|
| 594 |
+
owned_by='google'
|
| 595 |
+
)
|
| 596 |
+
model_list.append(model_to_dict(claude_opus_model))
|
| 597 |
+
|
| 598 |
+
log.info(f"[ANTIGRAVITY] Fetched {len(model_list)} available models")
|
| 599 |
+
return model_list
|
| 600 |
+
else:
|
| 601 |
+
log.error(f"[ANTIGRAVITY] Failed to fetch models ({response.status_code}): {response.text[:500]}")
|
| 602 |
+
return []
|
| 603 |
+
|
| 604 |
+
except Exception as e:
|
| 605 |
+
import traceback
|
| 606 |
+
log.error(f"[ANTIGRAVITY] Failed to fetch models: {e}")
|
| 607 |
+
log.error(f"[ANTIGRAVITY] Traceback: {traceback.format_exc()}")
|
| 608 |
+
return []
|
| 609 |
+
|
| 610 |
+
|
| 611 |
+
async def fetch_quota_info(access_token: str) -> Dict[str, Any]:
|
| 612 |
+
"""
|
| 613 |
+
获取指定凭证的额度信息
|
| 614 |
+
|
| 615 |
+
Args:
|
| 616 |
+
access_token: Antigravity 访问令牌
|
| 617 |
+
|
| 618 |
+
Returns:
|
| 619 |
+
包含额度信息的字典,格式为:
|
| 620 |
+
{
|
| 621 |
+
"success": True/False,
|
| 622 |
+
"models": {
|
| 623 |
+
"model_name": {
|
| 624 |
+
"remaining": 0.95,
|
| 625 |
+
"resetTime": "12-20 10:30",
|
| 626 |
+
"resetTimeRaw": "2025-12-20T02:30:00Z"
|
| 627 |
+
}
|
| 628 |
+
},
|
| 629 |
+
"error": "错误信息" (仅在失败时)
|
| 630 |
+
}
|
| 631 |
+
"""
|
| 632 |
+
|
| 633 |
+
headers = build_antigravity_headers(access_token)
|
| 634 |
+
|
| 635 |
+
try:
|
| 636 |
+
antigravity_url = await get_antigravity_api_url()
|
| 637 |
+
|
| 638 |
+
response = await post_async(
|
| 639 |
+
url=f"{antigravity_url}/v1internal:fetchAvailableModels",
|
| 640 |
+
json={},
|
| 641 |
+
headers=headers,
|
| 642 |
+
timeout=30.0
|
| 643 |
+
)
|
| 644 |
+
|
| 645 |
+
if response.status_code == 200:
|
| 646 |
+
data = response.json()
|
| 647 |
+
log.debug(f"[ANTIGRAVITY QUOTA] Raw response: {json.dumps(data, ensure_ascii=False)[:500]}")
|
| 648 |
+
|
| 649 |
+
quota_info = {}
|
| 650 |
+
|
| 651 |
+
if 'models' in data and isinstance(data['models'], dict):
|
| 652 |
+
for model_id, model_data in data['models'].items():
|
| 653 |
+
if isinstance(model_data, dict) and 'quotaInfo' in model_data:
|
| 654 |
+
quota = model_data['quotaInfo']
|
| 655 |
+
remaining = quota.get('remainingFraction', 0)
|
| 656 |
+
reset_time_raw = quota.get('resetTime', '')
|
| 657 |
+
|
| 658 |
+
# 转换为北京时间
|
| 659 |
+
reset_time_beijing = 'N/A'
|
| 660 |
+
if reset_time_raw:
|
| 661 |
+
try:
|
| 662 |
+
utc_date = datetime.fromisoformat(reset_time_raw.replace('Z', '+00:00'))
|
| 663 |
+
# 转换为北京时间 (UTC+8)
|
| 664 |
+
from datetime import timedelta
|
| 665 |
+
beijing_date = utc_date + timedelta(hours=8)
|
| 666 |
+
reset_time_beijing = beijing_date.strftime('%m-%d %H:%M')
|
| 667 |
+
except Exception as e:
|
| 668 |
+
log.warning(f"[ANTIGRAVITY QUOTA] Failed to parse reset time: {e}")
|
| 669 |
+
|
| 670 |
+
quota_info[model_id] = {
|
| 671 |
+
"remaining": remaining,
|
| 672 |
+
"resetTime": reset_time_beijing,
|
| 673 |
+
"resetTimeRaw": reset_time_raw
|
| 674 |
+
}
|
| 675 |
+
|
| 676 |
+
return {
|
| 677 |
+
"success": True,
|
| 678 |
+
"models": quota_info
|
| 679 |
+
}
|
| 680 |
+
else:
|
| 681 |
+
log.error(f"[ANTIGRAVITY QUOTA] Failed to fetch quota ({response.status_code}): {response.text[:500]}")
|
| 682 |
+
return {
|
| 683 |
+
"success": False,
|
| 684 |
+
"error": f"API返回错误: {response.status_code}"
|
| 685 |
+
}
|
| 686 |
+
|
| 687 |
+
except Exception as e:
|
| 688 |
+
import traceback
|
| 689 |
+
log.error(f"[ANTIGRAVITY QUOTA] Failed to fetch quota: {e}")
|
| 690 |
+
log.error(f"[ANTIGRAVITY QUOTA] Traceback: {traceback.format_exc()}")
|
| 691 |
+
return {
|
| 692 |
+
"success": False,
|
| 693 |
+
"error": str(e)
|
| 694 |
+
}
|
src/api/geminicli.py
ADDED
|
@@ -0,0 +1,597 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 19 |
+
|
| 20 |
+
from fastapi import Response
|
| 21 |
+
from config import get_code_assist_endpoint, get_auto_ban_error_codes
|
| 22 |
+
from src.api.utils import get_model_group
|
| 23 |
+
from log import log
|
| 24 |
+
|
| 25 |
+
from src.credential_manager import CredentialManager
|
| 26 |
+
from src.httpx_client import stream_post_async, post_async
|
| 27 |
+
|
| 28 |
+
# 导入共同的基础功能
|
| 29 |
+
from src.api.utils import (
|
| 30 |
+
handle_error_with_retry,
|
| 31 |
+
get_retry_config,
|
| 32 |
+
record_api_call_success,
|
| 33 |
+
record_api_call_error,
|
| 34 |
+
parse_and_log_cooldown,
|
| 35 |
+
)
|
| 36 |
+
from src.utils import GEMINICLI_USER_AGENT
|
| 37 |
+
|
| 38 |
+
# ==================== 全局凭证管理器 ====================
|
| 39 |
+
|
| 40 |
+
# 全局凭证管理器实例(单例模式)
|
| 41 |
+
_credential_manager: Optional[CredentialManager] = None
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
async def _get_credential_manager() -> CredentialManager:
|
| 45 |
+
"""
|
| 46 |
+
获取全局凭证管理器实例
|
| 47 |
+
|
| 48 |
+
Returns:
|
| 49 |
+
CredentialManager实例
|
| 50 |
+
"""
|
| 51 |
+
global _credential_manager
|
| 52 |
+
if not _credential_manager:
|
| 53 |
+
_credential_manager = CredentialManager()
|
| 54 |
+
await _credential_manager.initialize()
|
| 55 |
+
return _credential_manager
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
# ==================== 请求准备 ====================
|
| 59 |
+
|
| 60 |
+
async def prepare_request_headers_and_payload(
|
| 61 |
+
payload: dict, credential_data: dict, target_url: str
|
| 62 |
+
):
|
| 63 |
+
"""
|
| 64 |
+
从凭证数据准备请求头和最终payload
|
| 65 |
+
|
| 66 |
+
Args:
|
| 67 |
+
payload: 原始请求payload
|
| 68 |
+
credential_data: 凭证数据字典
|
| 69 |
+
target_url: 目标URL
|
| 70 |
+
|
| 71 |
+
Returns:
|
| 72 |
+
元组: (headers, final_payload, target_url)
|
| 73 |
+
|
| 74 |
+
Raises:
|
| 75 |
+
Exception: 如果凭证中缺少必要字段
|
| 76 |
+
"""
|
| 77 |
+
token = credential_data.get("token") or credential_data.get("access_token", "")
|
| 78 |
+
if not token:
|
| 79 |
+
raise Exception("凭证中没有找到有效的访问令牌(token或access_token字段)")
|
| 80 |
+
|
| 81 |
+
source_request = payload.get("request", {})
|
| 82 |
+
|
| 83 |
+
# 内部API使用Bearer Token和项目ID
|
| 84 |
+
headers = {
|
| 85 |
+
"Authorization": f"Bearer {token}",
|
| 86 |
+
"Content-Type": "application/json",
|
| 87 |
+
"User-Agent": GEMINICLI_USER_AGENT,
|
| 88 |
+
}
|
| 89 |
+
project_id = credential_data.get("project_id", "")
|
| 90 |
+
if not project_id:
|
| 91 |
+
raise Exception("项目ID不存在于凭证数据中")
|
| 92 |
+
final_payload = {
|
| 93 |
+
"model": payload.get("model"),
|
| 94 |
+
"project": project_id,
|
| 95 |
+
"request": source_request,
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
return headers, final_payload, target_url
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
# ==================== 新的流式和非流式请求函数 ====================
|
| 102 |
+
|
| 103 |
+
async def stream_request(
|
| 104 |
+
body: Dict[str, Any],
|
| 105 |
+
native: bool = False,
|
| 106 |
+
headers: Optional[Dict[str, str]] = None,
|
| 107 |
+
):
|
| 108 |
+
"""
|
| 109 |
+
流式请求函数
|
| 110 |
+
|
| 111 |
+
Args:
|
| 112 |
+
body: 请求体
|
| 113 |
+
native: 是否返回原生bytes流,False则返回str流
|
| 114 |
+
headers: 额外的请求头
|
| 115 |
+
|
| 116 |
+
Yields:
|
| 117 |
+
Response对象(错误时)或 bytes流/str流(成功时)
|
| 118 |
+
"""
|
| 119 |
+
# 获取凭证管理器
|
| 120 |
+
credential_manager = await _get_credential_manager()
|
| 121 |
+
|
| 122 |
+
model_name = body.get("model", "")
|
| 123 |
+
model_group = get_model_group(model_name)
|
| 124 |
+
|
| 125 |
+
# 1. 获取有效凭证
|
| 126 |
+
cred_result = await credential_manager.get_valid_credential(
|
| 127 |
+
mode="geminicli", model_key=model_group
|
| 128 |
+
)
|
| 129 |
+
|
| 130 |
+
if not cred_result:
|
| 131 |
+
# 如果返回值是None,直接返回错误500
|
| 132 |
+
yield Response(
|
| 133 |
+
content=json.dumps({"error": "当前无可用凭证"}),
|
| 134 |
+
status_code=500,
|
| 135 |
+
media_type="application/json"
|
| 136 |
+
)
|
| 137 |
+
return
|
| 138 |
+
|
| 139 |
+
current_file, credential_data = cred_result
|
| 140 |
+
|
| 141 |
+
# 2. 构建URL和请求头
|
| 142 |
+
try:
|
| 143 |
+
auth_headers, final_payload, target_url = await prepare_request_headers_and_payload(
|
| 144 |
+
body, credential_data,
|
| 145 |
+
f"{await get_code_assist_endpoint()}/v1internal:streamGenerateContent?alt=sse"
|
| 146 |
+
)
|
| 147 |
+
|
| 148 |
+
# 合并自定义headers
|
| 149 |
+
if headers:
|
| 150 |
+
auth_headers.update(headers)
|
| 151 |
+
|
| 152 |
+
except Exception as e:
|
| 153 |
+
log.error(f"准备请求失败: {e}")
|
| 154 |
+
yield Response(
|
| 155 |
+
content=json.dumps({"error": f"准备请求失败: {str(e)}"}),
|
| 156 |
+
status_code=500,
|
| 157 |
+
media_type="application/json"
|
| 158 |
+
)
|
| 159 |
+
return
|
| 160 |
+
|
| 161 |
+
# 3. 调用stream_post_async进行请求
|
| 162 |
+
retry_config = await get_retry_config()
|
| 163 |
+
max_retries = retry_config["max_retries"]
|
| 164 |
+
retry_interval = retry_config["retry_interval"]
|
| 165 |
+
|
| 166 |
+
DISABLE_ERROR_CODES = await get_auto_ban_error_codes() # 禁用凭证的错误码
|
| 167 |
+
last_error_response = None # 记录最后一次的错误响应
|
| 168 |
+
|
| 169 |
+
for attempt in range(max_retries + 1):
|
| 170 |
+
success_recorded = False # 标记是否已记录成功
|
| 171 |
+
|
| 172 |
+
try:
|
| 173 |
+
async for chunk in stream_post_async(
|
| 174 |
+
url=target_url,
|
| 175 |
+
body=final_payload,
|
| 176 |
+
native=native,
|
| 177 |
+
headers=auth_headers
|
| 178 |
+
):
|
| 179 |
+
# 判断是否是Response对象
|
| 180 |
+
if isinstance(chunk, Response):
|
| 181 |
+
status_code = chunk.status_code
|
| 182 |
+
last_error_response = chunk # 记录最后一次错误
|
| 183 |
+
|
| 184 |
+
# 如果错误码是429或者不在禁用码当中,做好记录后进行重试
|
| 185 |
+
if status_code == 429 or status_code not in DISABLE_ERROR_CODES:
|
| 186 |
+
# 解析错误响应内容
|
| 187 |
+
try:
|
| 188 |
+
error_body = chunk.body.decode('utf-8') if isinstance(chunk.body, bytes) else str(chunk.body)
|
| 189 |
+
log.warning(f"流式请求失败 (status={status_code}), 凭证: {current_file}, 响应: {error_body[:500]}")
|
| 190 |
+
except Exception:
|
| 191 |
+
log.warning(f"流式请求失败 (status={status_code}), 凭证: {current_file}")
|
| 192 |
+
|
| 193 |
+
# 记录错误
|
| 194 |
+
cooldown_until = None
|
| 195 |
+
if status_code == 429:
|
| 196 |
+
# 尝试解析冷却时间
|
| 197 |
+
try:
|
| 198 |
+
error_body = chunk.body.decode('utf-8') if isinstance(chunk.body, bytes) else str(chunk.body)
|
| 199 |
+
cooldown_until = await parse_and_log_cooldown(error_body, mode="geminicli")
|
| 200 |
+
except Exception:
|
| 201 |
+
pass
|
| 202 |
+
|
| 203 |
+
await record_api_call_error(
|
| 204 |
+
credential_manager, current_file, status_code,
|
| 205 |
+
cooldown_until, mode="geminicli", model_key=model_group
|
| 206 |
+
)
|
| 207 |
+
|
| 208 |
+
# 检查是否应该重试
|
| 209 |
+
should_retry = await handle_error_with_retry(
|
| 210 |
+
credential_manager, status_code, current_file,
|
| 211 |
+
retry_config["retry_enabled"], attempt, max_retries, retry_interval,
|
| 212 |
+
mode="geminicli"
|
| 213 |
+
)
|
| 214 |
+
|
| 215 |
+
if should_retry and attempt < max_retries:
|
| 216 |
+
# 重新获取凭证并重试
|
| 217 |
+
log.info(f"[STREAM] 重试请求 (attempt {attempt + 2}/{max_retries + 1})...")
|
| 218 |
+
await asyncio.sleep(retry_interval)
|
| 219 |
+
|
| 220 |
+
# 获取新凭证
|
| 221 |
+
cred_result = await credential_manager.get_valid_credential(
|
| 222 |
+
mode="geminicli", model_key=model_group
|
| 223 |
+
)
|
| 224 |
+
if not cred_result:
|
| 225 |
+
log.error("[STREAM] 重试时无可用凭证")
|
| 226 |
+
yield Response(
|
| 227 |
+
content=json.dumps({"error": "当前无可用凭证"}),
|
| 228 |
+
status_code=500,
|
| 229 |
+
media_type="application/json"
|
| 230 |
+
)
|
| 231 |
+
return
|
| 232 |
+
|
| 233 |
+
current_file, credential_data = cred_result
|
| 234 |
+
auth_headers, final_payload, target_url = await prepare_request_headers_and_payload(
|
| 235 |
+
body, credential_data,
|
| 236 |
+
f"{await get_code_assist_endpoint()}/v1internal:streamGenerateContent?alt=sse"
|
| 237 |
+
)
|
| 238 |
+
if headers:
|
| 239 |
+
auth_headers.update(headers)
|
| 240 |
+
break # 跳出内层循环,重新请求
|
| 241 |
+
else:
|
| 242 |
+
# 不重试,直接返回原始错误
|
| 243 |
+
log.error(f"[STREAM] 达到最大重试次数或不应重试,返回原始错误")
|
| 244 |
+
yield chunk
|
| 245 |
+
return
|
| 246 |
+
else:
|
| 247 |
+
# 错误码在禁用码当中,直接返回,无需重试
|
| 248 |
+
try:
|
| 249 |
+
error_body = chunk.body.decode('utf-8') if isinstance(chunk.body, bytes) else str(chunk.body)
|
| 250 |
+
log.error(f"流式请求失败,禁用错误码 (status={status_code}), 凭证: {current_file}, 响应: {error_body[:500]}")
|
| 251 |
+
except Exception:
|
| 252 |
+
log.error(f"流式请求失败,禁用错误码 (status={status_code}), 凭证: {current_file}")
|
| 253 |
+
await record_api_call_error(
|
| 254 |
+
credential_manager, current_file, status_code,
|
| 255 |
+
None, mode="geminicli", model_key=model_group
|
| 256 |
+
)
|
| 257 |
+
yield chunk
|
| 258 |
+
return
|
| 259 |
+
else:
|
| 260 |
+
# 不是Response,说明是真流,直接yield返回
|
| 261 |
+
# 只在第一个chunk时记录成功
|
| 262 |
+
if not success_recorded:
|
| 263 |
+
await record_api_call_success(
|
| 264 |
+
credential_manager, current_file, mode="geminicli", model_key=model_group
|
| 265 |
+
)
|
| 266 |
+
success_recorded = True
|
| 267 |
+
|
| 268 |
+
yield chunk
|
| 269 |
+
|
| 270 |
+
# 流式请求成功完成,退出重试循环
|
| 271 |
+
return
|
| 272 |
+
|
| 273 |
+
except Exception as e:
|
| 274 |
+
log.error(f"流式请求异常: {e}, 凭证: {current_file}")
|
| 275 |
+
if attempt < max_retries:
|
| 276 |
+
log.info(f"[STREAM] 异常后重试 (attempt {attempt + 2}/{max_retries + 1})...")
|
| 277 |
+
await asyncio.sleep(retry_interval)
|
| 278 |
+
continue
|
| 279 |
+
else:
|
| 280 |
+
# 所有重试都失败,返回最后一次的错误(如果有)或500错误
|
| 281 |
+
log.error(f"[STREAM] 所有重试均失败,最后异常: {e}")
|
| 282 |
+
yield last_error_response
|
| 283 |
+
|
| 284 |
+
|
| 285 |
+
async def non_stream_request(
|
| 286 |
+
body: Dict[str, Any],
|
| 287 |
+
headers: Optional[Dict[str, str]] = None,
|
| 288 |
+
) -> Response:
|
| 289 |
+
"""
|
| 290 |
+
非流式请求函数
|
| 291 |
+
|
| 292 |
+
Args:
|
| 293 |
+
body: 请求体
|
| 294 |
+
native: 保留参数以保持接口一致性(实际未使用)
|
| 295 |
+
headers: 额外的请求头
|
| 296 |
+
|
| 297 |
+
Returns:
|
| 298 |
+
Response对象
|
| 299 |
+
"""
|
| 300 |
+
# 获取凭证管理器
|
| 301 |
+
credential_manager = await _get_credential_manager()
|
| 302 |
+
|
| 303 |
+
model_name = body.get("model", "")
|
| 304 |
+
model_group = get_model_group(model_name)
|
| 305 |
+
|
| 306 |
+
# 1. 获取有效凭证
|
| 307 |
+
cred_result = await credential_manager.get_valid_credential(
|
| 308 |
+
mode="geminicli", model_key=model_group
|
| 309 |
+
)
|
| 310 |
+
|
| 311 |
+
if not cred_result:
|
| 312 |
+
# 如果返回值是None,直接返回错误500
|
| 313 |
+
return Response(
|
| 314 |
+
content=json.dumps({"error": "当前无可用凭证"}),
|
| 315 |
+
status_code=500,
|
| 316 |
+
media_type="application/json"
|
| 317 |
+
)
|
| 318 |
+
|
| 319 |
+
current_file, credential_data = cred_result
|
| 320 |
+
|
| 321 |
+
# 2. 构建URL和请求头
|
| 322 |
+
try:
|
| 323 |
+
auth_headers, final_payload, target_url = await prepare_request_headers_and_payload(
|
| 324 |
+
body, credential_data,
|
| 325 |
+
f"{await get_code_assist_endpoint()}/v1internal:generateContent"
|
| 326 |
+
)
|
| 327 |
+
|
| 328 |
+
# 合并自定义headers
|
| 329 |
+
if headers:
|
| 330 |
+
auth_headers.update(headers)
|
| 331 |
+
|
| 332 |
+
except Exception as e:
|
| 333 |
+
log.error(f"准备请求失败: {e}")
|
| 334 |
+
return Response(
|
| 335 |
+
content=json.dumps({"error": f"准备请求失败: {str(e)}"}),
|
| 336 |
+
status_code=500,
|
| 337 |
+
media_type="application/json"
|
| 338 |
+
)
|
| 339 |
+
|
| 340 |
+
# 3. 调用post_async进行请求
|
| 341 |
+
retry_config = await get_retry_config()
|
| 342 |
+
max_retries = retry_config["max_retries"]
|
| 343 |
+
retry_interval = retry_config["retry_interval"]
|
| 344 |
+
|
| 345 |
+
DISABLE_ERROR_CODES = await get_auto_ban_error_codes() # 禁用凭证的错误码
|
| 346 |
+
last_error_response = None # 记录最后一次的错误响应
|
| 347 |
+
|
| 348 |
+
for attempt in range(max_retries + 1):
|
| 349 |
+
try:
|
| 350 |
+
response = await post_async(
|
| 351 |
+
url=target_url,
|
| 352 |
+
json=final_payload,
|
| 353 |
+
headers=auth_headers,
|
| 354 |
+
timeout=300.0
|
| 355 |
+
)
|
| 356 |
+
|
| 357 |
+
status_code = response.status_code
|
| 358 |
+
|
| 359 |
+
# 成功
|
| 360 |
+
if status_code == 200:
|
| 361 |
+
await record_api_call_success(
|
| 362 |
+
credential_manager, current_file, mode="geminicli", model_key=model_group
|
| 363 |
+
)
|
| 364 |
+
# 创建响应头,移除压缩相关的header避免重复解压
|
| 365 |
+
response_headers = dict(response.headers)
|
| 366 |
+
response_headers.pop('content-encoding', None)
|
| 367 |
+
response_headers.pop('content-length', None)
|
| 368 |
+
|
| 369 |
+
return Response(
|
| 370 |
+
content=response.content,
|
| 371 |
+
status_code=200,
|
| 372 |
+
headers=response_headers
|
| 373 |
+
)
|
| 374 |
+
|
| 375 |
+
# 失败 - 记录最后一次错误
|
| 376 |
+
# 创建响应头,移除压缩相关的header避免重复解压
|
| 377 |
+
error_headers = dict(response.headers)
|
| 378 |
+
error_headers.pop('content-encoding', None)
|
| 379 |
+
error_headers.pop('content-length', None)
|
| 380 |
+
|
| 381 |
+
last_error_response = Response(
|
| 382 |
+
content=response.content,
|
| 383 |
+
status_code=status_code,
|
| 384 |
+
headers=error_headers
|
| 385 |
+
)
|
| 386 |
+
|
| 387 |
+
# 判断是否需要重试
|
| 388 |
+
if status_code == 429 or status_code not in DISABLE_ERROR_CODES:
|
| 389 |
+
try:
|
| 390 |
+
error_text = response.text
|
| 391 |
+
log.warning(f"非流式请求失败 (status={status_code}), 凭证: {current_file}, 响应: {error_text[:500]}")
|
| 392 |
+
except Exception:
|
| 393 |
+
log.warning(f"非流式请求失败 (status={status_code}), 凭证: {current_file}")
|
| 394 |
+
|
| 395 |
+
# 记录错误
|
| 396 |
+
cooldown_until = None
|
| 397 |
+
if status_code == 429:
|
| 398 |
+
# 尝试解析冷却时间
|
| 399 |
+
try:
|
| 400 |
+
error_text = response.text
|
| 401 |
+
cooldown_until = await parse_and_log_cooldown(error_text, mode="geminicli")
|
| 402 |
+
except Exception:
|
| 403 |
+
pass
|
| 404 |
+
|
| 405 |
+
await record_api_call_error(
|
| 406 |
+
credential_manager, current_file, status_code,
|
| 407 |
+
cooldown_until, mode="geminicli", model_key=model_group
|
| 408 |
+
)
|
| 409 |
+
|
| 410 |
+
# 检查是否应该重试
|
| 411 |
+
should_retry = await handle_error_with_retry(
|
| 412 |
+
credential_manager, status_code, current_file,
|
| 413 |
+
retry_config["retry_enabled"], attempt, max_retries, retry_interval,
|
| 414 |
+
mode="geminicli"
|
| 415 |
+
)
|
| 416 |
+
|
| 417 |
+
if should_retry and attempt < max_retries:
|
| 418 |
+
# 重新获取凭证并重试
|
| 419 |
+
log.info(f"[NON-STREAM] 重试请求 (attempt {attempt + 2}/{max_retries + 1})...")
|
| 420 |
+
await asyncio.sleep(retry_interval)
|
| 421 |
+
|
| 422 |
+
# 获取新凭证
|
| 423 |
+
cred_result = await credential_manager.get_valid_credential(
|
| 424 |
+
mode="geminicli", model_key=model_group
|
| 425 |
+
)
|
| 426 |
+
if not cred_result:
|
| 427 |
+
log.error("[NON-STREAM] 重试时无可用凭证")
|
| 428 |
+
return Response(
|
| 429 |
+
content=json.dumps({"error": "当前无可用凭证"}),
|
| 430 |
+
status_code=500,
|
| 431 |
+
media_type="application/json"
|
| 432 |
+
)
|
| 433 |
+
|
| 434 |
+
current_file, credential_data = cred_result
|
| 435 |
+
auth_headers, final_payload, target_url = await prepare_request_headers_and_payload(
|
| 436 |
+
body, credential_data,
|
| 437 |
+
f"{await get_code_assist_endpoint()}/v1internal:generateContent"
|
| 438 |
+
)
|
| 439 |
+
if headers:
|
| 440 |
+
auth_headers.update(headers)
|
| 441 |
+
continue # 重试
|
| 442 |
+
else:
|
| 443 |
+
# 不重试,直接返回原始错误
|
| 444 |
+
log.error(f"[NON-STREAM] 达到最大重试次数或不应重试,返回原始错误")
|
| 445 |
+
return last_error_response
|
| 446 |
+
else:
|
| 447 |
+
# 错误码在禁用码当中,直接返回,无需重试
|
| 448 |
+
try:
|
| 449 |
+
error_text = response.text
|
| 450 |
+
log.error(f"非流式请求失败,禁用错误码 (status={status_code}), 凭证: {current_file}, 响应: {error_text[:500]}")
|
| 451 |
+
except Exception:
|
| 452 |
+
log.error(f"非流式请求失败,禁用错误码 (status={status_code}), 凭证: {current_file}")
|
| 453 |
+
await record_api_call_error(
|
| 454 |
+
credential_manager, current_file, status_code,
|
| 455 |
+
None, mode="geminicli", model_key=model_group
|
| 456 |
+
)
|
| 457 |
+
return last_error_response
|
| 458 |
+
|
| 459 |
+
except Exception as e:
|
| 460 |
+
log.error(f"非流式请求异常: {e}, 凭证: {current_file}")
|
| 461 |
+
if attempt < max_retries:
|
| 462 |
+
log.info(f"[NON-STREAM] 异常后重试 (attempt {attempt + 2}/{max_retries + 1})...")
|
| 463 |
+
await asyncio.sleep(retry_interval)
|
| 464 |
+
continue
|
| 465 |
+
else:
|
| 466 |
+
# 所有重试都失败,返回最后一次的错误(如果有)或500错误
|
| 467 |
+
log.error(f"[NON-STREAM] 所有重试均失败,最后异常: {e}")
|
| 468 |
+
if last_error_response:
|
| 469 |
+
return last_error_response
|
| 470 |
+
else:
|
| 471 |
+
return Response(
|
| 472 |
+
content=json.dumps({"error": f"请求异常: {str(e)}"}),
|
| 473 |
+
status_code=500,
|
| 474 |
+
media_type="application/json"
|
| 475 |
+
)
|
| 476 |
+
|
| 477 |
+
# 所有重试都失败,返回最后一次的原始错误
|
| 478 |
+
log.error("[NON-STREAM] 所有重试均失败")
|
| 479 |
+
return last_error_response
|
| 480 |
+
|
| 481 |
+
|
| 482 |
+
# ==================== 测试代码 ====================
|
| 483 |
+
|
| 484 |
+
if __name__ == "__main__":
|
| 485 |
+
"""
|
| 486 |
+
测试代码:演示API返回的流式和非流式数据格式
|
| 487 |
+
运行方式: python src/api/geminicli.py
|
| 488 |
+
"""
|
| 489 |
+
print("=" * 80)
|
| 490 |
+
print("GeminiCli API 测试")
|
| 491 |
+
print("=" * 80)
|
| 492 |
+
|
| 493 |
+
# 测试请求体
|
| 494 |
+
test_body = {
|
| 495 |
+
"model": "gemini-2.5-flash",
|
| 496 |
+
"request": {
|
| 497 |
+
"contents": [
|
| 498 |
+
{
|
| 499 |
+
"role": "user",
|
| 500 |
+
"parts": [{"text": "Hello, tell me a joke in one sentence."}]
|
| 501 |
+
}
|
| 502 |
+
]
|
| 503 |
+
}
|
| 504 |
+
}
|
| 505 |
+
|
| 506 |
+
async def test_stream_request():
|
| 507 |
+
"""测试流式请求"""
|
| 508 |
+
print("\n" + "=" * 80)
|
| 509 |
+
print("【测试1】流式请求 (stream_request with native=False)")
|
| 510 |
+
print("=" * 80)
|
| 511 |
+
print(f"请求体: {json.dumps(test_body, indent=2, ensure_ascii=False)}\n")
|
| 512 |
+
|
| 513 |
+
print("流式响应数据 (每个chunk):")
|
| 514 |
+
print("-" * 80)
|
| 515 |
+
|
| 516 |
+
chunk_count = 0
|
| 517 |
+
async for chunk in stream_request(body=test_body, native=False):
|
| 518 |
+
chunk_count += 1
|
| 519 |
+
if isinstance(chunk, Response):
|
| 520 |
+
# 错误响应
|
| 521 |
+
print(f"\n❌ 错误响应:")
|
| 522 |
+
print(f" 状态码: {chunk.status_code}")
|
| 523 |
+
print(f" Content-Type: {chunk.headers.get('content-type', 'N/A')}")
|
| 524 |
+
try:
|
| 525 |
+
content = chunk.body.decode('utf-8') if isinstance(chunk.body, bytes) else str(chunk.body)
|
| 526 |
+
print(f" 内容: {content}")
|
| 527 |
+
except Exception as e:
|
| 528 |
+
print(f" 内容解析失败: {e}")
|
| 529 |
+
else:
|
| 530 |
+
# 正常的流式数据块 (str类型)
|
| 531 |
+
print(f"\nChunk #{chunk_count}:")
|
| 532 |
+
print(f" 类型: {type(chunk).__name__}")
|
| 533 |
+
print(f" 长度: {len(chunk) if hasattr(chunk, '__len__') else 'N/A'}")
|
| 534 |
+
print(f" 内容预览: {repr(chunk[:200] if len(chunk) > 200 else chunk)}")
|
| 535 |
+
|
| 536 |
+
# 如果是SSE格式,尝试解析
|
| 537 |
+
if isinstance(chunk, str) and chunk.startswith("data: "):
|
| 538 |
+
try:
|
| 539 |
+
data_line = chunk.strip()
|
| 540 |
+
if data_line.startswith("data: "):
|
| 541 |
+
json_str = data_line[6:] # 去掉 "data: " 前缀
|
| 542 |
+
json_data = json.loads(json_str)
|
| 543 |
+
print(f" 解析后的JSON: {json.dumps(json_data, indent=4, ensure_ascii=False)}")
|
| 544 |
+
except Exception as e:
|
| 545 |
+
print(f" SSE解析尝试失败: {e}")
|
| 546 |
+
|
| 547 |
+
print(f"\n总共收到 {chunk_count} 个chunk")
|
| 548 |
+
|
| 549 |
+
async def test_non_stream_request():
|
| 550 |
+
"""测试非流式请求"""
|
| 551 |
+
print("\n" + "=" * 80)
|
| 552 |
+
print("【测试2】非流式请求 (non_stream_request)")
|
| 553 |
+
print("=" * 80)
|
| 554 |
+
print(f"请求体: {json.dumps(test_body, indent=2, ensure_ascii=False)}\n")
|
| 555 |
+
|
| 556 |
+
response = await non_stream_request(body=test_body)
|
| 557 |
+
|
| 558 |
+
print("非流式响应数据:")
|
| 559 |
+
print("-" * 80)
|
| 560 |
+
print(f"状态码: {response.status_code}")
|
| 561 |
+
print(f"Content-Type: {response.headers.get('content-type', 'N/A')}")
|
| 562 |
+
print(f"\n响应头: {dict(response.headers)}\n")
|
| 563 |
+
|
| 564 |
+
try:
|
| 565 |
+
content = response.body.decode('utf-8') if isinstance(response.body, bytes) else str(response.body)
|
| 566 |
+
print(f"响应内容 (原始):\n{content}\n")
|
| 567 |
+
|
| 568 |
+
# 尝试解析JSON
|
| 569 |
+
try:
|
| 570 |
+
json_data = json.loads(content)
|
| 571 |
+
print(f"响应内容 (格式化JSON):")
|
| 572 |
+
print(json.dumps(json_data, indent=2, ensure_ascii=False))
|
| 573 |
+
except json.JSONDecodeError:
|
| 574 |
+
print("(非JSON格式)")
|
| 575 |
+
except Exception as e:
|
| 576 |
+
print(f"内容解析失败: {e}")
|
| 577 |
+
|
| 578 |
+
async def main():
|
| 579 |
+
"""主测试函数"""
|
| 580 |
+
try:
|
| 581 |
+
# 测试流式请求
|
| 582 |
+
await test_stream_request()
|
| 583 |
+
|
| 584 |
+
# 测试非流式请求
|
| 585 |
+
await test_non_stream_request()
|
| 586 |
+
|
| 587 |
+
print("\n" + "=" * 80)
|
| 588 |
+
print("测试完成")
|
| 589 |
+
print("=" * 80)
|
| 590 |
+
|
| 591 |
+
except Exception as e:
|
| 592 |
+
print(f"\n❌ 测试过程中出现异常: {e}")
|
| 593 |
+
import traceback
|
| 594 |
+
traceback.print_exc()
|
| 595 |
+
|
| 596 |
+
# 运行测试
|
| 597 |
+
asyncio.run(main())
|
src/api/utils.py
ADDED
|
@@ -0,0 +1,497 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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. 导致凭证封禁的错误(AUTO_BAN_ERROR_CODES配置)
|
| 82 |
+
|
| 83 |
+
Args:
|
| 84 |
+
credential_manager: 凭证管理器实例
|
| 85 |
+
status_code: HTTP状态码
|
| 86 |
+
credential_name: 凭证名称
|
| 87 |
+
retry_enabled: 是否启用重试
|
| 88 |
+
attempt: 当前重试次数
|
| 89 |
+
max_retries: 最大重试次数
|
| 90 |
+
retry_interval: 重试间隔
|
| 91 |
+
mode: 模式(geminicli 或 antigravity)
|
| 92 |
+
|
| 93 |
+
Returns:
|
| 94 |
+
bool: True表示需要继续重试,False表示不需要重试
|
| 95 |
+
"""
|
| 96 |
+
# 优先检查自动封禁
|
| 97 |
+
should_auto_ban = await check_should_auto_ban(status_code)
|
| 98 |
+
|
| 99 |
+
if should_auto_ban:
|
| 100 |
+
# 触发自动封禁
|
| 101 |
+
await handle_auto_ban(credential_manager, status_code, credential_name, mode)
|
| 102 |
+
|
| 103 |
+
# 自动封禁后,仍然尝试重试(会在下次循环中自动获取新凭证)
|
| 104 |
+
if retry_enabled and attempt < max_retries:
|
| 105 |
+
log.info(
|
| 106 |
+
f"[{mode.upper()} RETRY] Retrying with next credential after auto-ban "
|
| 107 |
+
f"(status {status_code}, attempt {attempt + 1}/{max_retries})"
|
| 108 |
+
)
|
| 109 |
+
await asyncio.sleep(retry_interval)
|
| 110 |
+
return True
|
| 111 |
+
return False
|
| 112 |
+
|
| 113 |
+
# 如果不触发自动封禁,仅对429错误进行重试
|
| 114 |
+
if status_code == 429 and retry_enabled and attempt < max_retries:
|
| 115 |
+
log.info(
|
| 116 |
+
f"[{mode.upper()} RETRY] 429 rate limit encountered, retrying "
|
| 117 |
+
f"(attempt {attempt + 1}/{max_retries})"
|
| 118 |
+
)
|
| 119 |
+
await asyncio.sleep(retry_interval)
|
| 120 |
+
return True
|
| 121 |
+
|
| 122 |
+
# 其他错误不进行重试
|
| 123 |
+
return False
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
# ==================== 重试配置获取 ====================
|
| 127 |
+
|
| 128 |
+
async def get_retry_config() -> Dict[str, Any]:
|
| 129 |
+
"""
|
| 130 |
+
获取重试配置
|
| 131 |
+
|
| 132 |
+
Returns:
|
| 133 |
+
包含重试配置的字典
|
| 134 |
+
"""
|
| 135 |
+
return {
|
| 136 |
+
"retry_enabled": await get_retry_429_enabled(),
|
| 137 |
+
"max_retries": await get_retry_429_max_retries(),
|
| 138 |
+
"retry_interval": await get_retry_429_interval(),
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
# ==================== API调用结果记录 ====================
|
| 143 |
+
|
| 144 |
+
async def record_api_call_success(
|
| 145 |
+
credential_manager: CredentialManager,
|
| 146 |
+
credential_name: str,
|
| 147 |
+
mode: str = "geminicli",
|
| 148 |
+
model_key: Optional[str] = None
|
| 149 |
+
) -> None:
|
| 150 |
+
"""
|
| 151 |
+
记录API调用成功
|
| 152 |
+
|
| 153 |
+
Args:
|
| 154 |
+
credential_manager: 凭证管理器实例
|
| 155 |
+
credential_name: 凭证名称
|
| 156 |
+
mode: 模式(geminicli 或 antigravity)
|
| 157 |
+
model_key: 模型键(用于模型级CD)
|
| 158 |
+
"""
|
| 159 |
+
if credential_manager and credential_name:
|
| 160 |
+
await credential_manager.record_api_call_result(
|
| 161 |
+
credential_name, True, mode=mode, model_key=model_key
|
| 162 |
+
)
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
async def record_api_call_error(
|
| 166 |
+
credential_manager: CredentialManager,
|
| 167 |
+
credential_name: str,
|
| 168 |
+
status_code: int,
|
| 169 |
+
cooldown_until: Optional[float] = None,
|
| 170 |
+
mode: str = "geminicli",
|
| 171 |
+
model_key: Optional[str] = None
|
| 172 |
+
) -> None:
|
| 173 |
+
"""
|
| 174 |
+
记录API调用错误
|
| 175 |
+
|
| 176 |
+
Args:
|
| 177 |
+
credential_manager: 凭证管理器实例
|
| 178 |
+
credential_name: 凭证名称
|
| 179 |
+
status_code: HTTP状态码
|
| 180 |
+
cooldown_until: 冷却截止时间(Unix时间戳)
|
| 181 |
+
mode: 模式(geminicli 或 antigravity)
|
| 182 |
+
model_key: 模型键(用于模型级CD)
|
| 183 |
+
"""
|
| 184 |
+
if credential_manager and credential_name:
|
| 185 |
+
await credential_manager.record_api_call_result(
|
| 186 |
+
credential_name,
|
| 187 |
+
False,
|
| 188 |
+
status_code,
|
| 189 |
+
cooldown_until=cooldown_until,
|
| 190 |
+
mode=mode,
|
| 191 |
+
model_key=model_key
|
| 192 |
+
)
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
# ==================== 429错误处理 ====================
|
| 196 |
+
|
| 197 |
+
async def parse_and_log_cooldown(
|
| 198 |
+
error_text: str,
|
| 199 |
+
mode: str = "geminicli"
|
| 200 |
+
) -> Optional[float]:
|
| 201 |
+
"""
|
| 202 |
+
解析并记录冷却时间
|
| 203 |
+
|
| 204 |
+
Args:
|
| 205 |
+
error_text: 错误响应文本
|
| 206 |
+
mode: 模式(geminicli 或 antigravity)
|
| 207 |
+
|
| 208 |
+
Returns:
|
| 209 |
+
冷却截止时间(Unix时间戳),如果解析失败则返回None
|
| 210 |
+
"""
|
| 211 |
+
try:
|
| 212 |
+
error_data = json.loads(error_text)
|
| 213 |
+
cooldown_until = parse_quota_reset_timestamp(error_data)
|
| 214 |
+
if cooldown_until:
|
| 215 |
+
log.info(
|
| 216 |
+
f"[{mode.upper()}] 检测到quota冷却时间: "
|
| 217 |
+
f"{datetime.fromtimestamp(cooldown_until, timezone.utc).isoformat()}"
|
| 218 |
+
)
|
| 219 |
+
return cooldown_until
|
| 220 |
+
except Exception as parse_err:
|
| 221 |
+
log.debug(f"[{mode.upper()}] Failed to parse cooldown time: {parse_err}")
|
| 222 |
+
return None
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
# ==================== 流式响应收集 ====================
|
| 226 |
+
|
| 227 |
+
async def collect_streaming_response(stream_generator) -> Response:
|
| 228 |
+
"""
|
| 229 |
+
将Gemini流式响应收集为一条完整的非流式响应
|
| 230 |
+
|
| 231 |
+
Args:
|
| 232 |
+
stream_generator: 流式响应生成器,产生 "data: {json}" 格式的行或Response对象
|
| 233 |
+
|
| 234 |
+
Returns:
|
| 235 |
+
Response: 合并后的完整响应对象
|
| 236 |
+
|
| 237 |
+
Example:
|
| 238 |
+
>>> async for line in stream_generator:
|
| 239 |
+
... # line format: "data: {...}" or Response object
|
| 240 |
+
>>> response = await collect_streaming_response(stream_generator)
|
| 241 |
+
"""
|
| 242 |
+
# 初始化响应结构
|
| 243 |
+
merged_response = {
|
| 244 |
+
"response": {
|
| 245 |
+
"candidates": [{
|
| 246 |
+
"content": {
|
| 247 |
+
"parts": [],
|
| 248 |
+
"role": "model"
|
| 249 |
+
},
|
| 250 |
+
"finishReason": None,
|
| 251 |
+
"safetyRatings": [],
|
| 252 |
+
"citationMetadata": None
|
| 253 |
+
}],
|
| 254 |
+
"usageMetadata": {
|
| 255 |
+
"promptTokenCount": 0,
|
| 256 |
+
"candidatesTokenCount": 0,
|
| 257 |
+
"totalTokenCount": 0
|
| 258 |
+
}
|
| 259 |
+
}
|
| 260 |
+
}
|
| 261 |
+
|
| 262 |
+
collected_text = [] # 用于收集文本内容
|
| 263 |
+
collected_thought_text = [] # 用于收集思维链内容
|
| 264 |
+
collected_other_parts = [] # 用于收集其他类型的parts(图片、文件等)
|
| 265 |
+
has_data = False
|
| 266 |
+
line_count = 0
|
| 267 |
+
|
| 268 |
+
log.debug("[STREAM COLLECTOR] Starting to collect streaming response")
|
| 269 |
+
|
| 270 |
+
try:
|
| 271 |
+
async for line in stream_generator:
|
| 272 |
+
line_count += 1
|
| 273 |
+
|
| 274 |
+
# 如果收到的是Response对象(错误),直接返回
|
| 275 |
+
if isinstance(line, Response):
|
| 276 |
+
log.debug(f"[STREAM COLLECTOR] 收到错误Response,状态码: {line.status_code}")
|
| 277 |
+
return line
|
| 278 |
+
|
| 279 |
+
# 处理 bytes 类型
|
| 280 |
+
if isinstance(line, bytes):
|
| 281 |
+
line_str = line.decode('utf-8', errors='ignore')
|
| 282 |
+
log.debug(f"[STREAM COLLECTOR] Processing bytes line {line_count}: {line_str[:200] if line_str else 'empty'}")
|
| 283 |
+
elif isinstance(line, str):
|
| 284 |
+
line_str = line
|
| 285 |
+
log.debug(f"[STREAM COLLECTOR] Processing line {line_count}: {line_str[:200] if line_str else 'empty'}")
|
| 286 |
+
else:
|
| 287 |
+
log.debug(f"[STREAM COLLECTOR] Skipping non-string/bytes line: {type(line)}")
|
| 288 |
+
continue
|
| 289 |
+
|
| 290 |
+
# 解析流式数据行
|
| 291 |
+
if not line_str.startswith("data: "):
|
| 292 |
+
log.debug(f"[STREAM COLLECTOR] Skipping line without 'data: ' prefix: {line_str[:100]}")
|
| 293 |
+
continue
|
| 294 |
+
|
| 295 |
+
raw = line_str[6:].strip()
|
| 296 |
+
if raw == "[DONE]":
|
| 297 |
+
log.debug("[STREAM COLLECTOR] Received [DONE] marker")
|
| 298 |
+
break
|
| 299 |
+
|
| 300 |
+
try:
|
| 301 |
+
log.debug(f"[STREAM COLLECTOR] Parsing JSON: {raw[:200]}")
|
| 302 |
+
chunk = json.loads(raw)
|
| 303 |
+
has_data = True
|
| 304 |
+
log.debug(f"[STREAM COLLECTOR] Chunk keys: {chunk.keys() if isinstance(chunk, dict) else type(chunk)}")
|
| 305 |
+
|
| 306 |
+
# 提取响应对象
|
| 307 |
+
response_obj = chunk.get("response", {})
|
| 308 |
+
if not response_obj:
|
| 309 |
+
log.debug("[STREAM COLLECTOR] No 'response' key in chunk, trying direct access")
|
| 310 |
+
response_obj = chunk # 尝试直接使用chunk
|
| 311 |
+
|
| 312 |
+
candidates = response_obj.get("candidates", [])
|
| 313 |
+
log.debug(f"[STREAM COLLECTOR] Found {len(candidates)} candidates")
|
| 314 |
+
if not candidates:
|
| 315 |
+
log.debug(f"[STREAM COLLECTOR] No candidates in chunk, chunk structure: {list(chunk.keys()) if isinstance(chunk, dict) else type(chunk)}")
|
| 316 |
+
continue
|
| 317 |
+
|
| 318 |
+
candidate = candidates[0]
|
| 319 |
+
|
| 320 |
+
# 收集文本内容
|
| 321 |
+
content = candidate.get("content", {})
|
| 322 |
+
parts = content.get("parts", [])
|
| 323 |
+
log.debug(f"[STREAM COLLECTOR] Processing {len(parts)} parts from candidate")
|
| 324 |
+
|
| 325 |
+
for part in parts:
|
| 326 |
+
if not isinstance(part, dict):
|
| 327 |
+
continue
|
| 328 |
+
|
| 329 |
+
# 处理文本内容
|
| 330 |
+
text = part.get("text", "")
|
| 331 |
+
if text:
|
| 332 |
+
# 区分普通文本和思维链
|
| 333 |
+
if part.get("thought", False):
|
| 334 |
+
collected_thought_text.append(text)
|
| 335 |
+
log.debug(f"[STREAM COLLECTOR] Collected thought text: {text[:100]}")
|
| 336 |
+
else:
|
| 337 |
+
collected_text.append(text)
|
| 338 |
+
log.debug(f"[STREAM COLLECTOR] Collected regular text: {text[:100]}")
|
| 339 |
+
# 处理非文本内容(图片、文件等)
|
| 340 |
+
elif "inlineData" in part or "fileData" in part or "executableCode" in part or "codeExecutionResult" in part:
|
| 341 |
+
collected_other_parts.append(part)
|
| 342 |
+
log.debug(f"[STREAM COLLECTOR] Collected non-text part: {list(part.keys())}")
|
| 343 |
+
|
| 344 |
+
# 收集其他信息(使用最后一个块的值)
|
| 345 |
+
if candidate.get("finishReason"):
|
| 346 |
+
merged_response["response"]["candidates"][0]["finishReason"] = candidate["finishReason"]
|
| 347 |
+
|
| 348 |
+
if candidate.get("safetyRatings"):
|
| 349 |
+
merged_response["response"]["candidates"][0]["safetyRatings"] = candidate["safetyRatings"]
|
| 350 |
+
|
| 351 |
+
if candidate.get("citationMetadata"):
|
| 352 |
+
merged_response["response"]["candidates"][0]["citationMetadata"] = candidate["citationMetadata"]
|
| 353 |
+
|
| 354 |
+
# 更新使用元数据
|
| 355 |
+
usage = response_obj.get("usageMetadata", {})
|
| 356 |
+
if usage:
|
| 357 |
+
merged_response["response"]["usageMetadata"].update(usage)
|
| 358 |
+
|
| 359 |
+
except json.JSONDecodeError as e:
|
| 360 |
+
log.debug(f"[STREAM COLLECTOR] Failed to parse JSON chunk: {e}")
|
| 361 |
+
continue
|
| 362 |
+
except Exception as e:
|
| 363 |
+
log.debug(f"[STREAM COLLECTOR] Error processing chunk: {e}")
|
| 364 |
+
continue
|
| 365 |
+
|
| 366 |
+
except Exception as e:
|
| 367 |
+
log.error(f"[STREAM COLLECTOR] Error collecting stream after {line_count} lines: {e}")
|
| 368 |
+
return Response(
|
| 369 |
+
content=json.dumps({"error": f"收集流式响应失败: {str(e)}"}),
|
| 370 |
+
status_code=500,
|
| 371 |
+
media_type="application/json"
|
| 372 |
+
)
|
| 373 |
+
|
| 374 |
+
log.debug(f"[STREAM COLLECTOR] Finished iteration, has_data={has_data}, line_count={line_count}")
|
| 375 |
+
|
| 376 |
+
# 如果没有收集到任何数据,返回错误
|
| 377 |
+
if not has_data:
|
| 378 |
+
log.error(f"[STREAM COLLECTOR] No data collected from stream after {line_count} lines")
|
| 379 |
+
return Response(
|
| 380 |
+
content=json.dumps({"error": "No data collected from stream"}),
|
| 381 |
+
status_code=500,
|
| 382 |
+
media_type="application/json"
|
| 383 |
+
)
|
| 384 |
+
|
| 385 |
+
# 组装最终的parts
|
| 386 |
+
final_parts = []
|
| 387 |
+
|
| 388 |
+
# 先添加思维链内容(如果有)
|
| 389 |
+
if collected_thought_text:
|
| 390 |
+
final_parts.append({
|
| 391 |
+
"text": "".join(collected_thought_text),
|
| 392 |
+
"thought": True
|
| 393 |
+
})
|
| 394 |
+
|
| 395 |
+
# 再添加普通文本内容
|
| 396 |
+
if collected_text:
|
| 397 |
+
final_parts.append({
|
| 398 |
+
"text": "".join(collected_text)
|
| 399 |
+
})
|
| 400 |
+
|
| 401 |
+
# 添加其他类型的parts(图片、文件等)
|
| 402 |
+
final_parts.extend(collected_other_parts)
|
| 403 |
+
|
| 404 |
+
# 如果没有任何内容,添加空文本
|
| 405 |
+
if not final_parts:
|
| 406 |
+
final_parts.append({"text": ""})
|
| 407 |
+
|
| 408 |
+
merged_response["response"]["candidates"][0]["content"]["parts"] = final_parts
|
| 409 |
+
|
| 410 |
+
log.info(f"[STREAM COLLECTOR] Collected {len(collected_text)} text chunks, {len(collected_thought_text)} thought chunks, and {len(collected_other_parts)} other parts")
|
| 411 |
+
|
| 412 |
+
# 去掉嵌套的 "response" 包装(Antigravity格式 -> 标准Gemini格式)
|
| 413 |
+
if "response" in merged_response and "candidates" not in merged_response:
|
| 414 |
+
log.debug(f"[STREAM COLLECTOR] 展开response包装")
|
| 415 |
+
merged_response = merged_response["response"]
|
| 416 |
+
|
| 417 |
+
# 返回纯JSON格式
|
| 418 |
+
return Response(
|
| 419 |
+
content=json.dumps(merged_response, ensure_ascii=False).encode('utf-8'),
|
| 420 |
+
status_code=200,
|
| 421 |
+
headers={},
|
| 422 |
+
media_type="application/json"
|
| 423 |
+
)
|
| 424 |
+
|
| 425 |
+
|
| 426 |
+
def parse_quota_reset_timestamp(error_response: dict) -> Optional[float]:
|
| 427 |
+
"""
|
| 428 |
+
从Google API错误响应中提取quota重置时间戳
|
| 429 |
+
|
| 430 |
+
Args:
|
| 431 |
+
error_response: Google API返���的错误响应字典
|
| 432 |
+
|
| 433 |
+
Returns:
|
| 434 |
+
Unix时间戳(秒),如果无法解析则返回None
|
| 435 |
+
|
| 436 |
+
示例错误响应:
|
| 437 |
+
{
|
| 438 |
+
"error": {
|
| 439 |
+
"code": 429,
|
| 440 |
+
"message": "You have exhausted your capacity...",
|
| 441 |
+
"status": "RESOURCE_EXHAUSTED",
|
| 442 |
+
"details": [
|
| 443 |
+
{
|
| 444 |
+
"@type": "type.googleapis.com/google.rpc.ErrorInfo",
|
| 445 |
+
"reason": "QUOTA_EXHAUSTED",
|
| 446 |
+
"metadata": {
|
| 447 |
+
"quotaResetTimeStamp": "2025-11-30T14:57:24Z",
|
| 448 |
+
"quotaResetDelay": "13h19m1.20964964s"
|
| 449 |
+
}
|
| 450 |
+
}
|
| 451 |
+
]
|
| 452 |
+
}
|
| 453 |
+
}
|
| 454 |
+
"""
|
| 455 |
+
try:
|
| 456 |
+
details = error_response.get("error", {}).get("details", [])
|
| 457 |
+
|
| 458 |
+
for detail in details:
|
| 459 |
+
if detail.get("@type") == "type.googleapis.com/google.rpc.ErrorInfo":
|
| 460 |
+
reset_timestamp_str = detail.get("metadata", {}).get("quotaResetTimeStamp")
|
| 461 |
+
|
| 462 |
+
if reset_timestamp_str:
|
| 463 |
+
if reset_timestamp_str.endswith("Z"):
|
| 464 |
+
reset_timestamp_str = reset_timestamp_str.replace("Z", "+00:00")
|
| 465 |
+
|
| 466 |
+
reset_dt = datetime.fromisoformat(reset_timestamp_str)
|
| 467 |
+
if reset_dt.tzinfo is None:
|
| 468 |
+
reset_dt = reset_dt.replace(tzinfo=timezone.utc)
|
| 469 |
+
|
| 470 |
+
return reset_dt.astimezone(timezone.utc).timestamp()
|
| 471 |
+
|
| 472 |
+
return None
|
| 473 |
+
|
| 474 |
+
except Exception:
|
| 475 |
+
return None
|
| 476 |
+
|
| 477 |
+
def get_model_group(model_name: str) -> str:
|
| 478 |
+
"""
|
| 479 |
+
获取模型组,用于 GCLI CD 机制。
|
| 480 |
+
|
| 481 |
+
Args:
|
| 482 |
+
model_name: 模型名称
|
| 483 |
+
|
| 484 |
+
Returns:
|
| 485 |
+
"pro" 或 "flash"
|
| 486 |
+
|
| 487 |
+
说明:
|
| 488 |
+
- pro 组: gemini-2.5-pro, gemini-3-pro-preview 共享额度
|
| 489 |
+
- flash 组: gemini-2.5-flash 单独额度
|
| 490 |
+
"""
|
| 491 |
+
|
| 492 |
+
# 判断模型组
|
| 493 |
+
if "flash" in model_name.lower():
|
| 494 |
+
return "flash"
|
| 495 |
+
else:
|
| 496 |
+
# pro 模型(包括 gemini-2.5-pro 和 gemini-3-pro-preview)
|
| 497 |
+
return "pro"
|
src/auth.py
ADDED
|
@@ -0,0 +1,1242 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
认证API模块
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import asyncio
|
| 6 |
+
import json
|
| 7 |
+
import secrets
|
| 8 |
+
import socket
|
| 9 |
+
import threading
|
| 10 |
+
import time
|
| 11 |
+
import uuid
|
| 12 |
+
from datetime import timezone
|
| 13 |
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
| 14 |
+
from typing import Any, Dict, List, Optional
|
| 15 |
+
from urllib.parse import parse_qs, urlparse
|
| 16 |
+
|
| 17 |
+
from config import get_config_value, get_antigravity_api_url, get_code_assist_endpoint
|
| 18 |
+
from log import log
|
| 19 |
+
|
| 20 |
+
from .google_oauth_api import (
|
| 21 |
+
Credentials,
|
| 22 |
+
Flow,
|
| 23 |
+
enable_required_apis,
|
| 24 |
+
fetch_project_id,
|
| 25 |
+
get_user_projects,
|
| 26 |
+
select_default_project,
|
| 27 |
+
)
|
| 28 |
+
from .storage_adapter import get_storage_adapter
|
| 29 |
+
from .utils import (
|
| 30 |
+
ANTIGRAVITY_CLIENT_ID,
|
| 31 |
+
ANTIGRAVITY_CLIENT_SECRET,
|
| 32 |
+
ANTIGRAVITY_SCOPES,
|
| 33 |
+
ANTIGRAVITY_USER_AGENT,
|
| 34 |
+
CALLBACK_HOST,
|
| 35 |
+
CLIENT_ID,
|
| 36 |
+
CLIENT_SECRET,
|
| 37 |
+
SCOPES,
|
| 38 |
+
GEMINICLI_USER_AGENT,
|
| 39 |
+
TOKEN_URL,
|
| 40 |
+
)
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
async def get_callback_port():
|
| 44 |
+
"""获取OAuth回调端口"""
|
| 45 |
+
return int(await get_config_value("oauth_callback_port", "11451", "OAUTH_CALLBACK_PORT"))
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def _prepare_credentials_data(credentials: Credentials, project_id: str, mode: str = "geminicli") -> Dict[str, Any]:
|
| 49 |
+
"""准备凭证数据字典(统一函数)"""
|
| 50 |
+
if mode == "antigravity":
|
| 51 |
+
creds_data = {
|
| 52 |
+
"client_id": ANTIGRAVITY_CLIENT_ID,
|
| 53 |
+
"client_secret": ANTIGRAVITY_CLIENT_SECRET,
|
| 54 |
+
"token": credentials.access_token,
|
| 55 |
+
"refresh_token": credentials.refresh_token,
|
| 56 |
+
"scopes": ANTIGRAVITY_SCOPES,
|
| 57 |
+
"token_uri": TOKEN_URL,
|
| 58 |
+
"project_id": project_id,
|
| 59 |
+
}
|
| 60 |
+
else:
|
| 61 |
+
creds_data = {
|
| 62 |
+
"client_id": CLIENT_ID,
|
| 63 |
+
"client_secret": CLIENT_SECRET,
|
| 64 |
+
"token": credentials.access_token,
|
| 65 |
+
"refresh_token": credentials.refresh_token,
|
| 66 |
+
"scopes": SCOPES,
|
| 67 |
+
"token_uri": TOKEN_URL,
|
| 68 |
+
"project_id": project_id,
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
if credentials.expires_at:
|
| 72 |
+
if credentials.expires_at.tzinfo is None:
|
| 73 |
+
expiry_utc = credentials.expires_at.replace(tzinfo=timezone.utc)
|
| 74 |
+
else:
|
| 75 |
+
expiry_utc = credentials.expires_at
|
| 76 |
+
creds_data["expiry"] = expiry_utc.isoformat()
|
| 77 |
+
|
| 78 |
+
return creds_data
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def _generate_random_project_id() -> str:
|
| 82 |
+
"""生成随机project_id(antigravity模式使用)"""
|
| 83 |
+
random_id = uuid.uuid4().hex[:8]
|
| 84 |
+
return f"projects/random-{random_id}/locations/global"
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def _cleanup_auth_flow_server(state: str):
|
| 88 |
+
"""清理认证流程的服务器资源"""
|
| 89 |
+
if state in auth_flows:
|
| 90 |
+
flow_data_to_clean = auth_flows[state]
|
| 91 |
+
try:
|
| 92 |
+
if flow_data_to_clean.get("server"):
|
| 93 |
+
server = flow_data_to_clean["server"]
|
| 94 |
+
port = flow_data_to_clean.get("callback_port")
|
| 95 |
+
async_shutdown_server(server, port)
|
| 96 |
+
except Exception as e:
|
| 97 |
+
log.debug(f"关闭服务器时出错: {e}")
|
| 98 |
+
del auth_flows[state]
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
class _OAuthLibPatcher:
|
| 102 |
+
"""oauthlib参数验证补丁的上下文管理器"""
|
| 103 |
+
def __init__(self):
|
| 104 |
+
import oauthlib.oauth2.rfc6749.parameters
|
| 105 |
+
self.module = oauthlib.oauth2.rfc6749.parameters
|
| 106 |
+
self.original_validate = None
|
| 107 |
+
|
| 108 |
+
def __enter__(self):
|
| 109 |
+
self.original_validate = self.module.validate_token_parameters
|
| 110 |
+
|
| 111 |
+
def patched_validate(params):
|
| 112 |
+
try:
|
| 113 |
+
return self.original_validate(params)
|
| 114 |
+
except Warning:
|
| 115 |
+
pass
|
| 116 |
+
|
| 117 |
+
self.module.validate_token_parameters = patched_validate
|
| 118 |
+
return self
|
| 119 |
+
|
| 120 |
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
| 121 |
+
if self.original_validate:
|
| 122 |
+
self.module.validate_token_parameters = self.original_validate
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
# 全局状态管理 - 严格限制大小
|
| 126 |
+
auth_flows = {} # 存储进行中的认证流程
|
| 127 |
+
MAX_AUTH_FLOWS = 20 # 严格限制最大认证流程数
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def cleanup_auth_flows_for_memory():
|
| 131 |
+
"""清理认证流程以释放内存"""
|
| 132 |
+
global auth_flows
|
| 133 |
+
cleanup_expired_flows()
|
| 134 |
+
# 如果还是太多,强制清理一些旧的流程
|
| 135 |
+
if len(auth_flows) > 10:
|
| 136 |
+
# 按创建时间排序,保留最新的10个
|
| 137 |
+
sorted_flows = sorted(
|
| 138 |
+
auth_flows.items(), key=lambda x: x[1].get("created_at", 0), reverse=True
|
| 139 |
+
)
|
| 140 |
+
new_auth_flows = dict(sorted_flows[:10])
|
| 141 |
+
|
| 142 |
+
# 清理被移除的流程
|
| 143 |
+
for state, flow_data in auth_flows.items():
|
| 144 |
+
if state not in new_auth_flows:
|
| 145 |
+
try:
|
| 146 |
+
if flow_data.get("server"):
|
| 147 |
+
server = flow_data["server"]
|
| 148 |
+
port = flow_data.get("callback_port")
|
| 149 |
+
async_shutdown_server(server, port)
|
| 150 |
+
except Exception:
|
| 151 |
+
pass
|
| 152 |
+
flow_data.clear()
|
| 153 |
+
|
| 154 |
+
auth_flows = new_auth_flows
|
| 155 |
+
log.info(f"强制清理认证流程,保留 {len(auth_flows)} 个最新流程")
|
| 156 |
+
|
| 157 |
+
return len(auth_flows)
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
async def find_available_port(start_port: int = None) -> int:
|
| 161 |
+
"""动态查找可用端口"""
|
| 162 |
+
if start_port is None:
|
| 163 |
+
start_port = await get_callback_port()
|
| 164 |
+
|
| 165 |
+
# 首先尝试默认端口
|
| 166 |
+
for port in range(start_port, start_port + 100): # 尝试100个端口
|
| 167 |
+
try:
|
| 168 |
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
| 169 |
+
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
| 170 |
+
s.bind(("0.0.0.0", port))
|
| 171 |
+
log.info(f"找到可用端口: {port}")
|
| 172 |
+
return port
|
| 173 |
+
except OSError:
|
| 174 |
+
continue
|
| 175 |
+
|
| 176 |
+
# 如果都不可用,让系统自动分配端口
|
| 177 |
+
try:
|
| 178 |
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
| 179 |
+
s.bind(("0.0.0.0", 0))
|
| 180 |
+
port = s.getsockname()[1]
|
| 181 |
+
log.info(f"系统分配可用端口: {port}")
|
| 182 |
+
return port
|
| 183 |
+
except OSError as e:
|
| 184 |
+
log.error(f"无法找到可用端口: {e}")
|
| 185 |
+
raise RuntimeError("无法找到可用端口")
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
def create_callback_server(port: int) -> HTTPServer:
|
| 189 |
+
"""创建指定端口的回调服务器,优化快速关闭"""
|
| 190 |
+
try:
|
| 191 |
+
# 服务器监听0.0.0.0
|
| 192 |
+
server = HTTPServer(("0.0.0.0", port), AuthCallbackHandler)
|
| 193 |
+
|
| 194 |
+
# 设置socket选项以支持快速关闭
|
| 195 |
+
server.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
| 196 |
+
# 设置较短的超时时间
|
| 197 |
+
server.timeout = 1.0
|
| 198 |
+
|
| 199 |
+
log.info(f"创建OAuth回调服务器,监听端口: {port}")
|
| 200 |
+
return server
|
| 201 |
+
except OSError as e:
|
| 202 |
+
log.error(f"创建端口{port}的服务器失败: {e}")
|
| 203 |
+
raise
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
class AuthCallbackHandler(BaseHTTPRequestHandler):
|
| 207 |
+
"""OAuth回调处理器"""
|
| 208 |
+
|
| 209 |
+
def do_GET(self):
|
| 210 |
+
query_components = parse_qs(urlparse(self.path).query)
|
| 211 |
+
code = query_components.get("code", [None])[0]
|
| 212 |
+
state = query_components.get("state", [None])[0]
|
| 213 |
+
|
| 214 |
+
log.info(f"收到OAuth回调: code={'已获取' if code else '未获取'}, state={state}")
|
| 215 |
+
|
| 216 |
+
if code and state and state in auth_flows:
|
| 217 |
+
# 更新流程状态
|
| 218 |
+
auth_flows[state]["code"] = code
|
| 219 |
+
auth_flows[state]["completed"] = True
|
| 220 |
+
|
| 221 |
+
log.info(f"OAuth回调成功处理: state={state}")
|
| 222 |
+
|
| 223 |
+
self.send_response(200)
|
| 224 |
+
self.send_header("Content-type", "text/html")
|
| 225 |
+
self.end_headers()
|
| 226 |
+
# 成功页面
|
| 227 |
+
self.wfile.write(
|
| 228 |
+
b"<h1>OAuth authentication successful!</h1><p>You can close this window. Please return to the original page and click 'Get Credentials' button.</p>"
|
| 229 |
+
)
|
| 230 |
+
else:
|
| 231 |
+
self.send_response(400)
|
| 232 |
+
self.send_header("Content-type", "text/html")
|
| 233 |
+
self.end_headers()
|
| 234 |
+
self.wfile.write(b"<h1>Authentication failed.</h1><p>Please try again.</p>")
|
| 235 |
+
|
| 236 |
+
def log_message(self, format, *args):
|
| 237 |
+
# 减少日志噪音
|
| 238 |
+
pass
|
| 239 |
+
|
| 240 |
+
|
| 241 |
+
async def create_auth_url(
|
| 242 |
+
project_id: Optional[str] = None, user_session: str = None, mode: str = "geminicli"
|
| 243 |
+
) -> Dict[str, Any]:
|
| 244 |
+
"""创建认证URL,支持动态端口分配"""
|
| 245 |
+
try:
|
| 246 |
+
# 动态分配端口
|
| 247 |
+
callback_port = await find_available_port()
|
| 248 |
+
callback_url = f"http://{CALLBACK_HOST}:{callback_port}"
|
| 249 |
+
|
| 250 |
+
# 立即启动回调服务器
|
| 251 |
+
try:
|
| 252 |
+
callback_server = create_callback_server(callback_port)
|
| 253 |
+
# 在后台线程中运行服务器
|
| 254 |
+
server_thread = threading.Thread(
|
| 255 |
+
target=callback_server.serve_forever,
|
| 256 |
+
daemon=True,
|
| 257 |
+
name=f"OAuth-Server-{callback_port}",
|
| 258 |
+
)
|
| 259 |
+
server_thread.start()
|
| 260 |
+
log.info(f"OAuth回调服务器已启动,端口: {callback_port}")
|
| 261 |
+
except Exception as e:
|
| 262 |
+
log.error(f"启动回调服务器失败: {e}")
|
| 263 |
+
return {
|
| 264 |
+
"success": False,
|
| 265 |
+
"error": f"无法启动OAuth回调服务器,端口{callback_port}: {str(e)}",
|
| 266 |
+
}
|
| 267 |
+
|
| 268 |
+
# 创建OAuth流程
|
| 269 |
+
# 根据模式选择配置
|
| 270 |
+
if mode == "antigravity":
|
| 271 |
+
client_id = ANTIGRAVITY_CLIENT_ID
|
| 272 |
+
client_secret = ANTIGRAVITY_CLIENT_SECRET
|
| 273 |
+
scopes = ANTIGRAVITY_SCOPES
|
| 274 |
+
else:
|
| 275 |
+
client_id = CLIENT_ID
|
| 276 |
+
client_secret = CLIENT_SECRET
|
| 277 |
+
scopes = SCOPES
|
| 278 |
+
|
| 279 |
+
flow = Flow(
|
| 280 |
+
client_id=client_id,
|
| 281 |
+
client_secret=client_secret,
|
| 282 |
+
scopes=scopes,
|
| 283 |
+
redirect_uri=callback_url,
|
| 284 |
+
)
|
| 285 |
+
|
| 286 |
+
# 生成状态标识符,包含用户会话信息
|
| 287 |
+
if user_session:
|
| 288 |
+
state = f"{user_session}_{str(uuid.uuid4())}"
|
| 289 |
+
else:
|
| 290 |
+
state = str(uuid.uuid4())
|
| 291 |
+
|
| 292 |
+
# 生成认证URL
|
| 293 |
+
auth_url = flow.get_auth_url(state=state)
|
| 294 |
+
|
| 295 |
+
# 严格控制认证流程数量 - 超过限制时立即清理最旧的
|
| 296 |
+
if len(auth_flows) >= MAX_AUTH_FLOWS:
|
| 297 |
+
# 清理最旧的认证流程
|
| 298 |
+
oldest_state = min(auth_flows.keys(), key=lambda k: auth_flows[k].get("created_at", 0))
|
| 299 |
+
try:
|
| 300 |
+
# 清理服务器资源
|
| 301 |
+
old_flow = auth_flows[oldest_state]
|
| 302 |
+
if old_flow.get("server"):
|
| 303 |
+
server = old_flow["server"]
|
| 304 |
+
port = old_flow.get("callback_port")
|
| 305 |
+
async_shutdown_server(server, port)
|
| 306 |
+
except Exception as e:
|
| 307 |
+
log.warning(f"Failed to cleanup old auth flow {oldest_state}: {e}")
|
| 308 |
+
|
| 309 |
+
del auth_flows[oldest_state]
|
| 310 |
+
log.debug(f"Removed oldest auth flow: {oldest_state}")
|
| 311 |
+
|
| 312 |
+
# 保存流程状态
|
| 313 |
+
auth_flows[state] = {
|
| 314 |
+
"flow": flow,
|
| 315 |
+
"project_id": project_id, # 可能为None,稍后在回调时确定
|
| 316 |
+
"user_session": user_session,
|
| 317 |
+
"callback_port": callback_port, # 存储分配的端口
|
| 318 |
+
"callback_url": callback_url, # 存储完整回调URL
|
| 319 |
+
"server": callback_server, # 存储服务器实例
|
| 320 |
+
"server_thread": server_thread, # 存储服务器线程
|
| 321 |
+
"code": None,
|
| 322 |
+
"completed": False,
|
| 323 |
+
"created_at": time.time(),
|
| 324 |
+
"auto_project_detection": project_id is None, # 标记是否需要自动检测项目ID
|
| 325 |
+
"mode": mode, # 凭证模式
|
| 326 |
+
}
|
| 327 |
+
|
| 328 |
+
# 清理过期的流程(30分钟)
|
| 329 |
+
cleanup_expired_flows()
|
| 330 |
+
|
| 331 |
+
log.info(f"OAuth流程已创建: state={state}, project_id={project_id}")
|
| 332 |
+
log.info(f"用户需要访问认证URL,然后OAuth会回调到 {callback_url}")
|
| 333 |
+
log.info(f"为此认证流程分配的端口: {callback_port}")
|
| 334 |
+
|
| 335 |
+
return {
|
| 336 |
+
"auth_url": auth_url,
|
| 337 |
+
"state": state,
|
| 338 |
+
"callback_port": callback_port,
|
| 339 |
+
"success": True,
|
| 340 |
+
"auto_project_detection": project_id is None,
|
| 341 |
+
"detected_project_id": project_id,
|
| 342 |
+
}
|
| 343 |
+
|
| 344 |
+
except Exception as e:
|
| 345 |
+
log.error(f"创建认证URL失败: {e}")
|
| 346 |
+
return {"success": False, "error": str(e)}
|
| 347 |
+
|
| 348 |
+
|
| 349 |
+
def wait_for_callback_sync(state: str, timeout: int = 300) -> Optional[str]:
|
| 350 |
+
"""同步等待OAuth回调完成,使用对应流程的专用服务器"""
|
| 351 |
+
if state not in auth_flows:
|
| 352 |
+
log.error(f"未找到状态为 {state} 的认证流程")
|
| 353 |
+
return None
|
| 354 |
+
|
| 355 |
+
flow_data = auth_flows[state]
|
| 356 |
+
callback_port = flow_data["callback_port"]
|
| 357 |
+
|
| 358 |
+
# 服务器已经在create_auth_url时启动了,这里只需要等待
|
| 359 |
+
log.info(f"等待OAuth回调完成,端口: {callback_port}")
|
| 360 |
+
|
| 361 |
+
# 等待回调完成
|
| 362 |
+
start_time = time.time()
|
| 363 |
+
while time.time() - start_time < timeout:
|
| 364 |
+
if flow_data.get("code"):
|
| 365 |
+
log.info("OAuth回调成功完成")
|
| 366 |
+
return flow_data["code"]
|
| 367 |
+
time.sleep(0.5) # 每0.5秒检查一次
|
| 368 |
+
|
| 369 |
+
# 刷新flow_data引用
|
| 370 |
+
if state in auth_flows:
|
| 371 |
+
flow_data = auth_flows[state]
|
| 372 |
+
|
| 373 |
+
log.warning(f"等待OAuth回调超时 ({timeout}秒)")
|
| 374 |
+
return None
|
| 375 |
+
|
| 376 |
+
|
| 377 |
+
async def complete_auth_flow(
|
| 378 |
+
project_id: Optional[str] = None, user_session: str = None
|
| 379 |
+
) -> Dict[str, Any]:
|
| 380 |
+
"""完成认证流程并保存凭证,支持自动检测项目ID"""
|
| 381 |
+
try:
|
| 382 |
+
# 查找对应的认证流程
|
| 383 |
+
state = None
|
| 384 |
+
flow_data = None
|
| 385 |
+
|
| 386 |
+
# 如果指定了project_id,先尝试匹配指定的项目
|
| 387 |
+
if project_id:
|
| 388 |
+
for s, data in auth_flows.items():
|
| 389 |
+
if data["project_id"] == project_id:
|
| 390 |
+
# 如果指定了用户会话,优先匹配相同会话的流程
|
| 391 |
+
if user_session and data.get("user_session") == user_session:
|
| 392 |
+
state = s
|
| 393 |
+
flow_data = data
|
| 394 |
+
break
|
| 395 |
+
# 如果没有指定会话,或没找到匹配会话的流程,使用第一个匹配项目ID的
|
| 396 |
+
elif not state:
|
| 397 |
+
state = s
|
| 398 |
+
flow_data = data
|
| 399 |
+
|
| 400 |
+
# 如果没有指定项目ID或没找到匹配的,查找需要自动检测项目ID的流程
|
| 401 |
+
if not state:
|
| 402 |
+
for s, data in auth_flows.items():
|
| 403 |
+
if data.get("auto_project_detection", False):
|
| 404 |
+
# 如果指定了用户会话,优先匹配相同会话的流程
|
| 405 |
+
if user_session and data.get("user_session") == user_session:
|
| 406 |
+
state = s
|
| 407 |
+
flow_data = data
|
| 408 |
+
break
|
| 409 |
+
# 使用第一个找到的需要自动检测的流程
|
| 410 |
+
elif not state:
|
| 411 |
+
state = s
|
| 412 |
+
flow_data = data
|
| 413 |
+
|
| 414 |
+
if not state or not flow_data:
|
| 415 |
+
return {"success": False, "error": "未找到对应的认证流程,请先点击获取认证链接"}
|
| 416 |
+
|
| 417 |
+
if not project_id:
|
| 418 |
+
project_id = flow_data.get("project_id")
|
| 419 |
+
if not project_id:
|
| 420 |
+
return {
|
| 421 |
+
"success": False,
|
| 422 |
+
"error": "缺少项目ID,请指定项目ID",
|
| 423 |
+
"requires_manual_project_id": True,
|
| 424 |
+
}
|
| 425 |
+
|
| 426 |
+
flow = flow_data["flow"]
|
| 427 |
+
|
| 428 |
+
# 如果还没有授权码,需要等待回调
|
| 429 |
+
if not flow_data.get("code"):
|
| 430 |
+
log.info(f"等待用户完成OAuth授权 (state: {state})")
|
| 431 |
+
auth_code = wait_for_callback_sync(state)
|
| 432 |
+
|
| 433 |
+
if not auth_code:
|
| 434 |
+
return {
|
| 435 |
+
"success": False,
|
| 436 |
+
"error": "未接收到授权回调,请确保完成了浏览器中的OAuth认证",
|
| 437 |
+
}
|
| 438 |
+
|
| 439 |
+
# 更新流程数据
|
| 440 |
+
auth_flows[state]["code"] = auth_code
|
| 441 |
+
auth_flows[state]["completed"] = True
|
| 442 |
+
else:
|
| 443 |
+
auth_code = flow_data["code"]
|
| 444 |
+
|
| 445 |
+
# 使用认证代码获取凭证
|
| 446 |
+
with _OAuthLibPatcher():
|
| 447 |
+
try:
|
| 448 |
+
credentials = await flow.exchange_code(auth_code)
|
| 449 |
+
# credentials 已经在 exchange_code 中获得
|
| 450 |
+
|
| 451 |
+
# 如果需要自动检测项目ID且没有提供项目ID
|
| 452 |
+
if flow_data.get("auto_project_detection", False) and not project_id:
|
| 453 |
+
log.info("尝试通过API获取用户项目列表...")
|
| 454 |
+
log.info(f"使用的token: {credentials.access_token[:20]}...")
|
| 455 |
+
log.info(f"Token过期时间: {credentials.expires_at}")
|
| 456 |
+
user_projects = await get_user_projects(credentials)
|
| 457 |
+
|
| 458 |
+
if user_projects:
|
| 459 |
+
# 如果只有一个项目,自动使用
|
| 460 |
+
if len(user_projects) == 1:
|
| 461 |
+
# Google API returns projectId in camelCase
|
| 462 |
+
project_id = user_projects[0].get("projectId")
|
| 463 |
+
if project_id:
|
| 464 |
+
flow_data["project_id"] = project_id
|
| 465 |
+
log.info(f"自动选择唯一项目: {project_id}")
|
| 466 |
+
# 如果有多个项目,尝试选择默认项目
|
| 467 |
+
else:
|
| 468 |
+
project_id = await select_default_project(user_projects)
|
| 469 |
+
if project_id:
|
| 470 |
+
flow_data["project_id"] = project_id
|
| 471 |
+
log.info(f"自动选择默认项目: {project_id}")
|
| 472 |
+
else:
|
| 473 |
+
# 返回项目列表让用户选择
|
| 474 |
+
return {
|
| 475 |
+
"success": False,
|
| 476 |
+
"error": "请从以下项目中选择一个",
|
| 477 |
+
"requires_project_selection": True,
|
| 478 |
+
"available_projects": [
|
| 479 |
+
{
|
| 480 |
+
# Google API returns projectId in camelCase
|
| 481 |
+
"project_id": p.get("projectId"),
|
| 482 |
+
"name": p.get("displayName") or p.get("projectId"),
|
| 483 |
+
"projectNumber": p.get("projectNumber"),
|
| 484 |
+
}
|
| 485 |
+
for p in user_projects
|
| 486 |
+
],
|
| 487 |
+
}
|
| 488 |
+
else:
|
| 489 |
+
# 如果无法获取项目列表,提示手动输入
|
| 490 |
+
return {
|
| 491 |
+
"success": False,
|
| 492 |
+
"error": "无法获取您的项目列表,请手动指定项目ID",
|
| 493 |
+
"requires_manual_project_id": True,
|
| 494 |
+
}
|
| 495 |
+
|
| 496 |
+
# 如果仍然没有项目ID,返回错误
|
| 497 |
+
if not project_id:
|
| 498 |
+
return {
|
| 499 |
+
"success": False,
|
| 500 |
+
"error": "缺少项目ID,请指定项目ID",
|
| 501 |
+
"requires_manual_project_id": True,
|
| 502 |
+
}
|
| 503 |
+
|
| 504 |
+
# 保存凭证
|
| 505 |
+
saved_filename = await save_credentials(credentials, project_id)
|
| 506 |
+
|
| 507 |
+
# 准备返回的凭证数据
|
| 508 |
+
creds_data = _prepare_credentials_data(credentials, project_id, mode="geminicli")
|
| 509 |
+
|
| 510 |
+
# 清理使用过的流程
|
| 511 |
+
_cleanup_auth_flow_server(state)
|
| 512 |
+
|
| 513 |
+
log.info("OAuth认证成功,凭证已保存")
|
| 514 |
+
return {
|
| 515 |
+
"success": True,
|
| 516 |
+
"credentials": creds_data,
|
| 517 |
+
"file_path": saved_filename,
|
| 518 |
+
"auto_detected_project": flow_data.get("auto_project_detection", False),
|
| 519 |
+
}
|
| 520 |
+
|
| 521 |
+
except Exception as e:
|
| 522 |
+
log.error(f"获取凭证失败: {e}")
|
| 523 |
+
return {"success": False, "error": f"获取凭证失败: {str(e)}"}
|
| 524 |
+
|
| 525 |
+
except Exception as e:
|
| 526 |
+
log.error(f"完成认证流程失败: {e}")
|
| 527 |
+
return {"success": False, "error": str(e)}
|
| 528 |
+
|
| 529 |
+
|
| 530 |
+
async def asyncio_complete_auth_flow(
|
| 531 |
+
project_id: Optional[str] = None, user_session: str = None, mode: str = "geminicli"
|
| 532 |
+
) -> Dict[str, Any]:
|
| 533 |
+
"""异步完成认证流程,支持自动检测项目ID"""
|
| 534 |
+
try:
|
| 535 |
+
log.info(
|
| 536 |
+
f"asyncio_complete_auth_flow开始执行: project_id={project_id}, user_session={user_session}"
|
| 537 |
+
)
|
| 538 |
+
|
| 539 |
+
# 查找对应的认证流程
|
| 540 |
+
state = None
|
| 541 |
+
flow_data = None
|
| 542 |
+
|
| 543 |
+
log.debug(f"当前所有auth_flows: {list(auth_flows.keys())}")
|
| 544 |
+
|
| 545 |
+
# 如果指定了project_id,先尝试匹配指定的项目
|
| 546 |
+
if project_id:
|
| 547 |
+
log.info(f"尝试匹配指定的项目ID: {project_id}")
|
| 548 |
+
for s, data in auth_flows.items():
|
| 549 |
+
if data["project_id"] == project_id:
|
| 550 |
+
# 如果指定了用户会话,优先匹配相同会话的流程
|
| 551 |
+
if user_session and data.get("user_session") == user_session:
|
| 552 |
+
state = s
|
| 553 |
+
flow_data = data
|
| 554 |
+
log.info(f"找到匹配的用户会话: {s}")
|
| 555 |
+
break
|
| 556 |
+
# 如果没有指定会话,或没找到匹配会话的流程,使用第一个匹配项目ID的
|
| 557 |
+
elif not state:
|
| 558 |
+
state = s
|
| 559 |
+
flow_data = data
|
| 560 |
+
log.info(f"找到匹配的项目ID: {s}")
|
| 561 |
+
|
| 562 |
+
# 如果没有指定项目ID或没找到匹配的,查找需要自动检测项目ID的流程
|
| 563 |
+
if not state:
|
| 564 |
+
log.info("没有找到指定项目的流程,查找自动检测流程")
|
| 565 |
+
# 首先尝试找到已完成的流程(有授权码的)
|
| 566 |
+
completed_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 |
+
if data.get("code"): # 优先选择已完成的
|
| 571 |
+
completed_flows.append((s, data, data.get("created_at", 0)))
|
| 572 |
+
|
| 573 |
+
# 如果有已完成的流程,选择最新的
|
| 574 |
+
if completed_flows:
|
| 575 |
+
completed_flows.sort(key=lambda x: x[2], reverse=True) # 按时间倒序
|
| 576 |
+
state, flow_data, _ = completed_flows[0]
|
| 577 |
+
log.info(f"找到已完成的最新认证流程: {state}")
|
| 578 |
+
else:
|
| 579 |
+
# 如果没有已完成的,找最新的未完成流程
|
| 580 |
+
pending_flows = []
|
| 581 |
+
for s, data in auth_flows.items():
|
| 582 |
+
if data.get("auto_project_detection", False):
|
| 583 |
+
if user_session and data.get("user_session") == user_session:
|
| 584 |
+
pending_flows.append((s, data, data.get("created_at", 0)))
|
| 585 |
+
elif not user_session:
|
| 586 |
+
pending_flows.append((s, data, data.get("created_at", 0)))
|
| 587 |
+
|
| 588 |
+
if pending_flows:
|
| 589 |
+
pending_flows.sort(key=lambda x: x[2], reverse=True) # 按时间倒序
|
| 590 |
+
state, flow_data, _ = pending_flows[0]
|
| 591 |
+
log.info(f"找到最新的待完成认证流程: {state}")
|
| 592 |
+
|
| 593 |
+
if not state or not flow_data:
|
| 594 |
+
log.error(f"未找到认证流程: state={state}, flow_data存在={bool(flow_data)}")
|
| 595 |
+
log.debug(f"当前所有flow_data: {list(auth_flows.keys())}")
|
| 596 |
+
return {"success": False, "error": "未找到对应的认证流程,请先点击获取认证链接"}
|
| 597 |
+
|
| 598 |
+
log.info(f"找到认证流程: state={state}")
|
| 599 |
+
log.info(
|
| 600 |
+
f"flow_data内容: project_id={flow_data.get('project_id')}, auto_project_detection={flow_data.get('auto_project_detection')}"
|
| 601 |
+
)
|
| 602 |
+
log.info(f"传入的project_id参数: {project_id}")
|
| 603 |
+
|
| 604 |
+
# 如果需要自动检测项目ID且没有提供项目ID
|
| 605 |
+
log.info(
|
| 606 |
+
f"检查auto_project_detection条件: auto_project_detection={flow_data.get('auto_project_detection', False)}, not project_id={not project_id}"
|
| 607 |
+
)
|
| 608 |
+
if flow_data.get("auto_project_detection", False) and not project_id:
|
| 609 |
+
log.info("跳过自动检测项目ID,进入等待阶段")
|
| 610 |
+
elif not project_id:
|
| 611 |
+
log.info("进入project_id检查分支")
|
| 612 |
+
project_id = flow_data.get("project_id")
|
| 613 |
+
if not project_id:
|
| 614 |
+
log.error("缺少项目ID,返回错误")
|
| 615 |
+
return {
|
| 616 |
+
"success": False,
|
| 617 |
+
"error": "缺少项目ID,请指定项目ID",
|
| 618 |
+
"requires_manual_project_id": True,
|
| 619 |
+
}
|
| 620 |
+
else:
|
| 621 |
+
log.info(f"使用提供的项目ID: {project_id}")
|
| 622 |
+
|
| 623 |
+
# 检查是否已经有授权码
|
| 624 |
+
log.info("开始检查OAuth授权码...")
|
| 625 |
+
log.info(f"等待state={state}的授权回调,回调端口: {flow_data.get('callback_port')}")
|
| 626 |
+
log.info(f"当前flow_data状��: completed={flow_data.get('completed')}, code存在={bool(flow_data.get('code'))}")
|
| 627 |
+
max_wait_time = 60 # 最多等待60秒
|
| 628 |
+
wait_interval = 1 # 每秒检查一次
|
| 629 |
+
waited = 0
|
| 630 |
+
|
| 631 |
+
while waited < max_wait_time:
|
| 632 |
+
if flow_data.get("code"):
|
| 633 |
+
log.info(f"检测到OAuth授权码,开始处理凭证 (等待时间: {waited}秒)")
|
| 634 |
+
break
|
| 635 |
+
|
| 636 |
+
# 每5秒输出一次提示
|
| 637 |
+
if waited % 5 == 0 and waited > 0:
|
| 638 |
+
log.info(f"仍在等待OAuth授权... ({waited}/{max_wait_time}秒)")
|
| 639 |
+
log.debug(f"当前state: {state}, flow_data keys: {list(flow_data.keys())}")
|
| 640 |
+
|
| 641 |
+
# 异步等待
|
| 642 |
+
await asyncio.sleep(wait_interval)
|
| 643 |
+
waited += wait_interval
|
| 644 |
+
|
| 645 |
+
# 刷新flow_data引用,因为可能被回调更新了
|
| 646 |
+
if state in auth_flows:
|
| 647 |
+
flow_data = auth_flows[state]
|
| 648 |
+
|
| 649 |
+
if not flow_data.get("code"):
|
| 650 |
+
log.error(f"等待OAuth回调超时,等待了{waited}秒")
|
| 651 |
+
return {
|
| 652 |
+
"success": False,
|
| 653 |
+
"error": "等待OAuth回调超时,请确保完成了浏览器中的认证并看到成功页面",
|
| 654 |
+
}
|
| 655 |
+
|
| 656 |
+
flow = flow_data["flow"]
|
| 657 |
+
auth_code = flow_data["code"]
|
| 658 |
+
|
| 659 |
+
log.info(f"开始使用授权码获取凭证: code={'***' + auth_code[-4:] if auth_code else 'None'}")
|
| 660 |
+
|
| 661 |
+
# 使用认证代码获取凭证
|
| 662 |
+
with _OAuthLibPatcher():
|
| 663 |
+
try:
|
| 664 |
+
log.info("调用flow.exchange_code...")
|
| 665 |
+
credentials = await flow.exchange_code(auth_code)
|
| 666 |
+
log.info(
|
| 667 |
+
f"成功获取凭证,token前缀: {credentials.access_token[:20] if credentials.access_token else 'None'}..."
|
| 668 |
+
)
|
| 669 |
+
|
| 670 |
+
log.info(
|
| 671 |
+
f"检查是否需要项目检测: auto_project_detection={flow_data.get('auto_project_detection')}, project_id={project_id}"
|
| 672 |
+
)
|
| 673 |
+
|
| 674 |
+
# 检查凭证模式
|
| 675 |
+
cred_mode = flow_data.get("mode", "geminicli") if flow_data.get("mode") else mode
|
| 676 |
+
if cred_mode == "antigravity":
|
| 677 |
+
log.info("Antigravity模式:从API获取project_id...")
|
| 678 |
+
# 使用API获取project_id
|
| 679 |
+
antigravity_url = await get_antigravity_api_url()
|
| 680 |
+
project_id = await fetch_project_id(
|
| 681 |
+
credentials.access_token,
|
| 682 |
+
ANTIGRAVITY_USER_AGENT,
|
| 683 |
+
antigravity_url
|
| 684 |
+
)
|
| 685 |
+
if project_id:
|
| 686 |
+
log.info(f"成功从API获取project_id: {project_id}")
|
| 687 |
+
else:
|
| 688 |
+
log.warning("无法从API获取project_id,回退到随机生成")
|
| 689 |
+
project_id = _generate_random_project_id()
|
| 690 |
+
log.info(f"生成的随机project_id: {project_id}")
|
| 691 |
+
|
| 692 |
+
# 保存antigravity凭证
|
| 693 |
+
saved_filename = await save_credentials(credentials, project_id, mode="antigravity")
|
| 694 |
+
|
| 695 |
+
# 准备返回的凭证数据
|
| 696 |
+
creds_data = _prepare_credentials_data(credentials, project_id, mode="antigravity")
|
| 697 |
+
|
| 698 |
+
# 清理使用过的流程
|
| 699 |
+
_cleanup_auth_flow_server(state)
|
| 700 |
+
|
| 701 |
+
log.info("Antigravity OAuth认证成功,凭证已保存")
|
| 702 |
+
return {
|
| 703 |
+
"success": True,
|
| 704 |
+
"credentials": creds_data,
|
| 705 |
+
"file_path": saved_filename,
|
| 706 |
+
"auto_detected_project": False,
|
| 707 |
+
"mode": "antigravity",
|
| 708 |
+
}
|
| 709 |
+
|
| 710 |
+
# 如果需要自动检测项目ID且没有提供项目ID(标准模式)
|
| 711 |
+
if flow_data.get("auto_project_detection", False) and not project_id:
|
| 712 |
+
log.info("标准模式:从API获取project_id...")
|
| 713 |
+
# 使用API获取project_id(使用标准模式的User-Agent)
|
| 714 |
+
code_assist_url = await get_code_assist_endpoint()
|
| 715 |
+
project_id = await fetch_project_id(
|
| 716 |
+
credentials.access_token,
|
| 717 |
+
GEMINICLI_USER_AGENT,
|
| 718 |
+
code_assist_url
|
| 719 |
+
)
|
| 720 |
+
if project_id:
|
| 721 |
+
flow_data["project_id"] = project_id
|
| 722 |
+
log.info(f"成功从API获取project_id: {project_id}")
|
| 723 |
+
# 自动启用必需的API服务
|
| 724 |
+
log.info("正在自动启用必需的API服务...")
|
| 725 |
+
await enable_required_apis(credentials, project_id)
|
| 726 |
+
else:
|
| 727 |
+
log.warning("无法从API获取project_id,回退到项目列表获取方式")
|
| 728 |
+
# 回退到原来的项目列表获取方式
|
| 729 |
+
user_projects = await get_user_projects(credentials)
|
| 730 |
+
|
| 731 |
+
if user_projects:
|
| 732 |
+
# 如果只有一个项目,自动使用
|
| 733 |
+
if len(user_projects) == 1:
|
| 734 |
+
# Google API returns projectId in camelCase
|
| 735 |
+
project_id = user_projects[0].get("projectId")
|
| 736 |
+
if project_id:
|
| 737 |
+
flow_data["project_id"] = project_id
|
| 738 |
+
log.info(f"自动选择唯一项目: {project_id}")
|
| 739 |
+
# 自动启用必需的API服务
|
| 740 |
+
log.info("正在自动启用必需的API服务...")
|
| 741 |
+
await enable_required_apis(credentials, project_id)
|
| 742 |
+
# 如果有多个项目,尝试选择默认项目
|
| 743 |
+
else:
|
| 744 |
+
project_id = await select_default_project(user_projects)
|
| 745 |
+
if project_id:
|
| 746 |
+
flow_data["project_id"] = project_id
|
| 747 |
+
log.info(f"自动选择默认项目: {project_id}")
|
| 748 |
+
# 自动启用必需的API服务
|
| 749 |
+
log.info("正在自动启用必需的API服务...")
|
| 750 |
+
await enable_required_apis(credentials, project_id)
|
| 751 |
+
else:
|
| 752 |
+
# 返回项目列表让用户选择
|
| 753 |
+
return {
|
| 754 |
+
"success": False,
|
| 755 |
+
"error": "请从以下项目中选择一个",
|
| 756 |
+
"requires_project_selection": True,
|
| 757 |
+
"available_projects": [
|
| 758 |
+
{
|
| 759 |
+
# Google API returns projectId in camelCase
|
| 760 |
+
"project_id": p.get("projectId"),
|
| 761 |
+
"name": p.get("displayName") or p.get("projectId"),
|
| 762 |
+
"projectNumber": p.get("projectNumber"),
|
| 763 |
+
}
|
| 764 |
+
for p in user_projects
|
| 765 |
+
],
|
| 766 |
+
}
|
| 767 |
+
else:
|
| 768 |
+
# 如果无法获取项目列表,提示手动输入
|
| 769 |
+
return {
|
| 770 |
+
"success": False,
|
| 771 |
+
"error": "无法获取您的项目列表,请手动指定项目ID",
|
| 772 |
+
"requires_manual_project_id": True,
|
| 773 |
+
}
|
| 774 |
+
elif project_id:
|
| 775 |
+
# 如果已经有项目ID(手动提供或环境检测),也尝试启用API服务
|
| 776 |
+
log.info("正在为已提供的项目ID自动启用必需的API服务...")
|
| 777 |
+
await enable_required_apis(credentials, project_id)
|
| 778 |
+
|
| 779 |
+
# 如果仍然没有项目ID,返回错误
|
| 780 |
+
if not project_id:
|
| 781 |
+
return {
|
| 782 |
+
"success": False,
|
| 783 |
+
"error": "缺少项目ID,请指定项目ID",
|
| 784 |
+
"requires_manual_project_id": True,
|
| 785 |
+
}
|
| 786 |
+
|
| 787 |
+
# 保存凭证
|
| 788 |
+
saved_filename = await save_credentials(credentials, project_id)
|
| 789 |
+
|
| 790 |
+
# 准备返回的凭证数据
|
| 791 |
+
creds_data = _prepare_credentials_data(credentials, project_id, mode="geminicli")
|
| 792 |
+
|
| 793 |
+
# 清理使用过的流程
|
| 794 |
+
_cleanup_auth_flow_server(state)
|
| 795 |
+
|
| 796 |
+
log.info("OAuth认证成功,凭证已保存")
|
| 797 |
+
return {
|
| 798 |
+
"success": True,
|
| 799 |
+
"credentials": creds_data,
|
| 800 |
+
"file_path": saved_filename,
|
| 801 |
+
"auto_detected_project": flow_data.get("auto_project_detection", False),
|
| 802 |
+
}
|
| 803 |
+
|
| 804 |
+
except Exception as e:
|
| 805 |
+
log.error(f"获取凭证失败: {e}")
|
| 806 |
+
return {"success": False, "error": f"获取凭证失败: {str(e)}"}
|
| 807 |
+
|
| 808 |
+
except Exception as e:
|
| 809 |
+
log.error(f"异步完成认证流程失败: {e}")
|
| 810 |
+
return {"success": False, "error": str(e)}
|
| 811 |
+
|
| 812 |
+
|
| 813 |
+
async def complete_auth_flow_from_callback_url(
|
| 814 |
+
callback_url: str, project_id: Optional[str] = None, mode: str = "geminicli"
|
| 815 |
+
) -> Dict[str, Any]:
|
| 816 |
+
"""从回调URL直接完成认证流程,无需启动本地服务器"""
|
| 817 |
+
try:
|
| 818 |
+
log.info(f"开始从回调URL完成认证: {callback_url}")
|
| 819 |
+
|
| 820 |
+
# 解析回调URL
|
| 821 |
+
parsed_url = urlparse(callback_url)
|
| 822 |
+
query_params = parse_qs(parsed_url.query)
|
| 823 |
+
|
| 824 |
+
# 验证必要参数
|
| 825 |
+
if "state" not in query_params or "code" not in query_params:
|
| 826 |
+
return {"success": False, "error": "回调URL缺少必要参数 (state 或 code)"}
|
| 827 |
+
|
| 828 |
+
state = query_params["state"][0]
|
| 829 |
+
code = query_params["code"][0]
|
| 830 |
+
|
| 831 |
+
log.info(f"从URL解析到: state={state}, code=xxx...")
|
| 832 |
+
|
| 833 |
+
# 检查是否有对应的认证流程
|
| 834 |
+
if state not in auth_flows:
|
| 835 |
+
return {
|
| 836 |
+
"success": False,
|
| 837 |
+
"error": f"未找到对应的认证流程,请先启动认证 (state: {state})",
|
| 838 |
+
}
|
| 839 |
+
|
| 840 |
+
flow_data = auth_flows[state]
|
| 841 |
+
flow = flow_data["flow"]
|
| 842 |
+
|
| 843 |
+
# 构造回调URL(使用flow中存储的redirect_uri)
|
| 844 |
+
redirect_uri = flow.redirect_uri
|
| 845 |
+
log.info(f"使用redirect_uri: {redirect_uri}")
|
| 846 |
+
|
| 847 |
+
try:
|
| 848 |
+
# 使用authorization code获取token
|
| 849 |
+
credentials = await flow.exchange_code(code)
|
| 850 |
+
log.info("成功获取访问令牌")
|
| 851 |
+
|
| 852 |
+
# 检查凭证模式
|
| 853 |
+
cred_mode = flow_data.get("mode", "geminicli") if flow_data.get("mode") else mode
|
| 854 |
+
if cred_mode == "antigravity":
|
| 855 |
+
log.info("Antigravity模式(从回调URL):从API获取project_id...")
|
| 856 |
+
# 使用API获取project_id
|
| 857 |
+
antigravity_url = await get_antigravity_api_url()
|
| 858 |
+
project_id = await fetch_project_id(
|
| 859 |
+
credentials.access_token,
|
| 860 |
+
ANTIGRAVITY_USER_AGENT,
|
| 861 |
+
antigravity_url
|
| 862 |
+
)
|
| 863 |
+
if project_id:
|
| 864 |
+
log.info(f"成功从API获取project_id: {project_id}")
|
| 865 |
+
else:
|
| 866 |
+
log.warning("无法从API获取project_id,回退到随机生成")
|
| 867 |
+
project_id = _generate_random_project_id()
|
| 868 |
+
log.info(f"生成的随机project_id: {project_id}")
|
| 869 |
+
|
| 870 |
+
# 保存antigravity凭证
|
| 871 |
+
saved_filename = await save_credentials(credentials, project_id, mode="antigravity")
|
| 872 |
+
|
| 873 |
+
# 准备返回的凭证数据
|
| 874 |
+
creds_data = _prepare_credentials_data(credentials, project_id, mode="antigravity")
|
| 875 |
+
|
| 876 |
+
# 清理使用过的流程
|
| 877 |
+
_cleanup_auth_flow_server(state)
|
| 878 |
+
|
| 879 |
+
log.info("从回调URL完成Antigravity OAuth认证成功,凭证已保存")
|
| 880 |
+
return {
|
| 881 |
+
"success": True,
|
| 882 |
+
"credentials": creds_data,
|
| 883 |
+
"file_path": saved_filename,
|
| 884 |
+
"auto_detected_project": False,
|
| 885 |
+
"mode": "antigravity",
|
| 886 |
+
}
|
| 887 |
+
|
| 888 |
+
# 标准模式的项目ID处理逻辑
|
| 889 |
+
detected_project_id = None
|
| 890 |
+
auto_detected = False
|
| 891 |
+
|
| 892 |
+
if not project_id:
|
| 893 |
+
# 尝试使用fetch_project_id自动获取项目ID
|
| 894 |
+
try:
|
| 895 |
+
log.info("标准模式:从API获取project_id...")
|
| 896 |
+
code_assist_url = await get_code_assist_endpoint()
|
| 897 |
+
detected_project_id = await fetch_project_id(
|
| 898 |
+
credentials.access_token,
|
| 899 |
+
GEMINICLI_USER_AGENT,
|
| 900 |
+
code_assist_url
|
| 901 |
+
)
|
| 902 |
+
if detected_project_id:
|
| 903 |
+
auto_detected = True
|
| 904 |
+
log.info(f"成功从API获取project_id: {detected_project_id}")
|
| 905 |
+
else:
|
| 906 |
+
log.warning("无法从API获取project_id,回退到项目列表获取方式")
|
| 907 |
+
# 回退到原来的项目列表获取方式
|
| 908 |
+
projects = await get_user_projects(credentials)
|
| 909 |
+
if projects:
|
| 910 |
+
if len(projects) == 1:
|
| 911 |
+
# 只有一个项目,自动使用
|
| 912 |
+
# Google API returns projectId in camelCase
|
| 913 |
+
detected_project_id = projects[0]["projectId"]
|
| 914 |
+
auto_detected = True
|
| 915 |
+
log.info(f"自动检测到唯一项目ID: {detected_project_id}")
|
| 916 |
+
else:
|
| 917 |
+
# 多个项目,自动选择第一个
|
| 918 |
+
# Google API returns projectId in camelCase
|
| 919 |
+
detected_project_id = projects[0]["projectId"]
|
| 920 |
+
auto_detected = True
|
| 921 |
+
log.info(
|
| 922 |
+
f"检测到{len(projects)}个项目,自动选择第一个: {detected_project_id}"
|
| 923 |
+
)
|
| 924 |
+
log.debug(f"其他可用项目: {[p['projectId'] for p in projects[1:]]}")
|
| 925 |
+
else:
|
| 926 |
+
# 没有项目访问权限
|
| 927 |
+
return {
|
| 928 |
+
"success": False,
|
| 929 |
+
"error": "未检测到可访问的项目,请检查权限或手动指定项目ID",
|
| 930 |
+
"requires_manual_project_id": True,
|
| 931 |
+
}
|
| 932 |
+
except Exception as e:
|
| 933 |
+
log.warning(f"自动检测项目ID失败: {e}")
|
| 934 |
+
return {
|
| 935 |
+
"success": False,
|
| 936 |
+
"error": f"自动检测项目ID失败: {str(e)},请手动指定项目ID",
|
| 937 |
+
"requires_manual_project_id": True,
|
| 938 |
+
}
|
| 939 |
+
else:
|
| 940 |
+
detected_project_id = project_id
|
| 941 |
+
|
| 942 |
+
# 启用必需的API服务
|
| 943 |
+
if detected_project_id:
|
| 944 |
+
try:
|
| 945 |
+
log.info(f"正在为项目 {detected_project_id} 启用必需的API服务...")
|
| 946 |
+
await enable_required_apis(credentials, detected_project_id)
|
| 947 |
+
except Exception as e:
|
| 948 |
+
log.warning(f"启用API服务失败: {e}")
|
| 949 |
+
|
| 950 |
+
# 保存凭证
|
| 951 |
+
saved_filename = await save_credentials(credentials, detected_project_id)
|
| 952 |
+
|
| 953 |
+
# 准备返回的凭证数据
|
| 954 |
+
creds_data = _prepare_credentials_data(credentials, detected_project_id, mode="geminicli")
|
| 955 |
+
|
| 956 |
+
# 清理使用过的流程
|
| 957 |
+
_cleanup_auth_flow_server(state)
|
| 958 |
+
|
| 959 |
+
log.info("从回调URL完成OAuth认证成功,凭证已保存")
|
| 960 |
+
return {
|
| 961 |
+
"success": True,
|
| 962 |
+
"credentials": creds_data,
|
| 963 |
+
"file_path": saved_filename,
|
| 964 |
+
"auto_detected_project": auto_detected,
|
| 965 |
+
}
|
| 966 |
+
|
| 967 |
+
except Exception as e:
|
| 968 |
+
log.error(f"从回调URL获取凭证失败: {e}")
|
| 969 |
+
return {"success": False, "error": f"获取凭证失败: {str(e)}"}
|
| 970 |
+
|
| 971 |
+
except Exception as e:
|
| 972 |
+
log.error(f"从回调URL完成认证流程失败: {e}")
|
| 973 |
+
return {"success": False, "error": str(e)}
|
| 974 |
+
|
| 975 |
+
|
| 976 |
+
async def save_credentials(creds: Credentials, project_id: str, mode: str = "geminicli") -> str:
|
| 977 |
+
"""通过统一存储系统保存凭证"""
|
| 978 |
+
# 生成文件名(使用project_id和时间戳)
|
| 979 |
+
timestamp = int(time.time())
|
| 980 |
+
|
| 981 |
+
# antigravity模式使用特殊前缀
|
| 982 |
+
if mode == "antigravity":
|
| 983 |
+
filename = f"ag_{project_id}-{timestamp}.json"
|
| 984 |
+
else:
|
| 985 |
+
filename = f"{project_id}-{timestamp}.json"
|
| 986 |
+
|
| 987 |
+
# 准备凭证数据
|
| 988 |
+
creds_data = _prepare_credentials_data(creds, project_id, mode)
|
| 989 |
+
|
| 990 |
+
# 通过存储适配器保存
|
| 991 |
+
storage_adapter = await get_storage_adapter()
|
| 992 |
+
success = await storage_adapter.store_credential(filename, creds_data, mode=mode)
|
| 993 |
+
|
| 994 |
+
if success:
|
| 995 |
+
# 创建默认状态记录
|
| 996 |
+
try:
|
| 997 |
+
default_state = {
|
| 998 |
+
"error_codes": [],
|
| 999 |
+
"disabled": False,
|
| 1000 |
+
"last_success": time.time(),
|
| 1001 |
+
"user_email": None,
|
| 1002 |
+
}
|
| 1003 |
+
await storage_adapter.update_credential_state(filename, default_state, mode=mode)
|
| 1004 |
+
log.info(f"凭证和状态已保存到: {filename} (mode={mode})")
|
| 1005 |
+
except Exception as e:
|
| 1006 |
+
log.warning(f"创建默认状态记录失败 {filename}: {e}")
|
| 1007 |
+
|
| 1008 |
+
return filename
|
| 1009 |
+
else:
|
| 1010 |
+
raise Exception(f"保存凭证失败: {filename}")
|
| 1011 |
+
|
| 1012 |
+
|
| 1013 |
+
def async_shutdown_server(server, port):
|
| 1014 |
+
"""异步关闭OAuth回调服务器,避免阻塞主流程"""
|
| 1015 |
+
|
| 1016 |
+
def shutdown_server_async():
|
| 1017 |
+
try:
|
| 1018 |
+
# 设置一个标志来跟踪关闭状态
|
| 1019 |
+
shutdown_completed = threading.Event()
|
| 1020 |
+
|
| 1021 |
+
def do_shutdown():
|
| 1022 |
+
try:
|
| 1023 |
+
server.shutdown()
|
| 1024 |
+
server.server_close()
|
| 1025 |
+
shutdown_completed.set()
|
| 1026 |
+
log.info(f"已关闭端口 {port} 的OAuth回调服务器")
|
| 1027 |
+
except Exception as e:
|
| 1028 |
+
shutdown_completed.set()
|
| 1029 |
+
log.debug(f"关闭服务器时出错: {e}")
|
| 1030 |
+
|
| 1031 |
+
# 在单独线程中执行关闭操作
|
| 1032 |
+
shutdown_worker = threading.Thread(target=do_shutdown, daemon=True)
|
| 1033 |
+
shutdown_worker.start()
|
| 1034 |
+
|
| 1035 |
+
# 等待最多5秒,如果超时就放弃等待
|
| 1036 |
+
if shutdown_completed.wait(timeout=5):
|
| 1037 |
+
log.debug(f"端口 {port} 服务器关闭完成")
|
| 1038 |
+
else:
|
| 1039 |
+
log.warning(f"端口 {port} 服务器关闭超时,但不阻塞主流程")
|
| 1040 |
+
|
| 1041 |
+
except Exception as e:
|
| 1042 |
+
log.debug(f"异步关闭服务器时出错: {e}")
|
| 1043 |
+
|
| 1044 |
+
# 在后台线程中关闭服务器,不阻塞主流程
|
| 1045 |
+
shutdown_thread = threading.Thread(target=shutdown_server_async, daemon=True)
|
| 1046 |
+
shutdown_thread.start()
|
| 1047 |
+
log.debug(f"开始异步关闭端口 {port} 的OAuth回调服务器")
|
| 1048 |
+
|
| 1049 |
+
|
| 1050 |
+
def cleanup_expired_flows():
|
| 1051 |
+
"""清理过期的认证流程"""
|
| 1052 |
+
current_time = time.time()
|
| 1053 |
+
EXPIRY_TIME = 600 # 10分钟过期
|
| 1054 |
+
|
| 1055 |
+
# 直接遍历删除,避免创建额外列表
|
| 1056 |
+
states_to_remove = [
|
| 1057 |
+
state
|
| 1058 |
+
for state, flow_data in auth_flows.items()
|
| 1059 |
+
if current_time - flow_data["created_at"] > EXPIRY_TIME
|
| 1060 |
+
]
|
| 1061 |
+
|
| 1062 |
+
# 批量清理,提高效率
|
| 1063 |
+
cleaned_count = 0
|
| 1064 |
+
for state in states_to_remove:
|
| 1065 |
+
flow_data = auth_flows.get(state)
|
| 1066 |
+
if flow_data:
|
| 1067 |
+
# 快速关闭可能存在的服务器
|
| 1068 |
+
try:
|
| 1069 |
+
if flow_data.get("server"):
|
| 1070 |
+
server = flow_data["server"]
|
| 1071 |
+
port = flow_data.get("callback_port")
|
| 1072 |
+
async_shutdown_server(server, port)
|
| 1073 |
+
except Exception as e:
|
| 1074 |
+
log.debug(f"清理过期流程时启动异步关闭服务器失败: {e}")
|
| 1075 |
+
|
| 1076 |
+
# 显式清理流程数据,释放内存
|
| 1077 |
+
flow_data.clear()
|
| 1078 |
+
del auth_flows[state]
|
| 1079 |
+
cleaned_count += 1
|
| 1080 |
+
|
| 1081 |
+
if cleaned_count > 0:
|
| 1082 |
+
log.info(f"清理了 {cleaned_count} 个过期的认证流程")
|
| 1083 |
+
|
| 1084 |
+
# 更积极的垃圾回收触发条件
|
| 1085 |
+
if len(auth_flows) > 20: # 降低阈值
|
| 1086 |
+
import gc
|
| 1087 |
+
|
| 1088 |
+
gc.collect()
|
| 1089 |
+
log.debug(f"触发垃圾回收,当前活跃认证流程数: {len(auth_flows)}")
|
| 1090 |
+
|
| 1091 |
+
|
| 1092 |
+
def get_auth_status(project_id: str) -> Dict[str, Any]:
|
| 1093 |
+
"""获取认证状态"""
|
| 1094 |
+
for state, flow_data in auth_flows.items():
|
| 1095 |
+
if flow_data["project_id"] == project_id:
|
| 1096 |
+
return {
|
| 1097 |
+
"status": "completed" if flow_data["completed"] else "pending",
|
| 1098 |
+
"state": state,
|
| 1099 |
+
"created_at": flow_data["created_at"],
|
| 1100 |
+
}
|
| 1101 |
+
|
| 1102 |
+
return {"status": "not_found"}
|
| 1103 |
+
|
| 1104 |
+
|
| 1105 |
+
# 鉴权功能 - 使用更小的数据结构
|
| 1106 |
+
auth_tokens = {} # 存储有效的认证令牌
|
| 1107 |
+
TOKEN_EXPIRY = 3600 # 1小时令牌过期时间
|
| 1108 |
+
|
| 1109 |
+
|
| 1110 |
+
async def verify_password(password: str) -> bool:
|
| 1111 |
+
"""验证密码(面板登录使用)"""
|
| 1112 |
+
from config import get_panel_password
|
| 1113 |
+
|
| 1114 |
+
correct_password = await get_panel_password()
|
| 1115 |
+
return password == correct_password
|
| 1116 |
+
|
| 1117 |
+
|
| 1118 |
+
def generate_auth_token() -> str:
|
| 1119 |
+
"""生成认证令牌"""
|
| 1120 |
+
# 清理过期令牌
|
| 1121 |
+
cleanup_expired_tokens()
|
| 1122 |
+
|
| 1123 |
+
token = secrets.token_urlsafe(32)
|
| 1124 |
+
# 只存储创建时间
|
| 1125 |
+
auth_tokens[token] = time.time()
|
| 1126 |
+
return token
|
| 1127 |
+
|
| 1128 |
+
|
| 1129 |
+
def verify_auth_token(token: str) -> bool:
|
| 1130 |
+
"""验证认证令牌"""
|
| 1131 |
+
if not token or token not in auth_tokens:
|
| 1132 |
+
return False
|
| 1133 |
+
|
| 1134 |
+
created_at = auth_tokens[token]
|
| 1135 |
+
|
| 1136 |
+
# 检查令牌是否过期 (使用更短的过期时间)
|
| 1137 |
+
if time.time() - created_at > TOKEN_EXPIRY:
|
| 1138 |
+
del auth_tokens[token]
|
| 1139 |
+
return False
|
| 1140 |
+
|
| 1141 |
+
return True
|
| 1142 |
+
|
| 1143 |
+
|
| 1144 |
+
def cleanup_expired_tokens():
|
| 1145 |
+
"""清理过期的认证令牌"""
|
| 1146 |
+
current_time = time.time()
|
| 1147 |
+
expired_tokens = [
|
| 1148 |
+
token
|
| 1149 |
+
for token, created_at in auth_tokens.items()
|
| 1150 |
+
if current_time - created_at > TOKEN_EXPIRY
|
| 1151 |
+
]
|
| 1152 |
+
|
| 1153 |
+
for token in expired_tokens:
|
| 1154 |
+
del auth_tokens[token]
|
| 1155 |
+
|
| 1156 |
+
if expired_tokens:
|
| 1157 |
+
log.debug(f"清理了 {len(expired_tokens)} 个过期的认证令牌")
|
| 1158 |
+
|
| 1159 |
+
|
| 1160 |
+
def invalidate_auth_token(token: str):
|
| 1161 |
+
"""使认证令牌失效"""
|
| 1162 |
+
if token in auth_tokens:
|
| 1163 |
+
del auth_tokens[token]
|
| 1164 |
+
|
| 1165 |
+
|
| 1166 |
+
# 文件验证和处理功能 - 使用统一存储系统
|
| 1167 |
+
def validate_credential_content(content: str) -> Dict[str, Any]:
|
| 1168 |
+
"""验证凭证内容格式"""
|
| 1169 |
+
try:
|
| 1170 |
+
creds_data = json.loads(content)
|
| 1171 |
+
|
| 1172 |
+
# 检查必要字段
|
| 1173 |
+
required_fields = ["client_id", "client_secret", "refresh_token", "token_uri"]
|
| 1174 |
+
missing_fields = [field for field in required_fields if field not in creds_data]
|
| 1175 |
+
|
| 1176 |
+
if missing_fields:
|
| 1177 |
+
return {"valid": False, "error": f'缺少必要字段: {", ".join(missing_fields)}'}
|
| 1178 |
+
|
| 1179 |
+
# 检查project_id
|
| 1180 |
+
if "project_id" not in creds_data:
|
| 1181 |
+
log.warning("认证文件缺少project_id字段")
|
| 1182 |
+
|
| 1183 |
+
return {"valid": True, "data": creds_data}
|
| 1184 |
+
|
| 1185 |
+
except json.JSONDecodeError as e:
|
| 1186 |
+
return {"valid": False, "error": f"JSON格式错误: {str(e)}"}
|
| 1187 |
+
except Exception as e:
|
| 1188 |
+
return {"valid": False, "error": f"文件验证失败: {str(e)}"}
|
| 1189 |
+
|
| 1190 |
+
|
| 1191 |
+
async def save_uploaded_credential(content: str, original_filename: str) -> Dict[str, Any]:
|
| 1192 |
+
"""通过统一存储系统保存上传的凭证"""
|
| 1193 |
+
try:
|
| 1194 |
+
# 验证内容格式
|
| 1195 |
+
validation = validate_credential_content(content)
|
| 1196 |
+
if not validation["valid"]:
|
| 1197 |
+
return {"success": False, "error": validation["error"]}
|
| 1198 |
+
|
| 1199 |
+
creds_data = validation["data"]
|
| 1200 |
+
|
| 1201 |
+
# 生成文件名
|
| 1202 |
+
project_id = creds_data.get("project_id", "unknown")
|
| 1203 |
+
timestamp = int(time.time())
|
| 1204 |
+
|
| 1205 |
+
# 从原文件名中提取有用信息
|
| 1206 |
+
import os
|
| 1207 |
+
|
| 1208 |
+
base_name = os.path.splitext(original_filename)[0]
|
| 1209 |
+
filename = f"{base_name}-{timestamp}.json"
|
| 1210 |
+
|
| 1211 |
+
# 通过存储适配器保存
|
| 1212 |
+
storage_adapter = await get_storage_adapter()
|
| 1213 |
+
success = await storage_adapter.store_credential(filename, creds_data)
|
| 1214 |
+
|
| 1215 |
+
if success:
|
| 1216 |
+
log.info(f"凭证文件已上传保存: {filename}")
|
| 1217 |
+
return {"success": True, "file_path": filename, "project_id": project_id}
|
| 1218 |
+
else:
|
| 1219 |
+
return {"success": False, "error": "保存到存储系统失败"}
|
| 1220 |
+
|
| 1221 |
+
except Exception as e:
|
| 1222 |
+
log.error(f"保存上传文件失败: {e}")
|
| 1223 |
+
return {"success": False, "error": str(e)}
|
| 1224 |
+
|
| 1225 |
+
|
| 1226 |
+
async def batch_upload_credentials(files_data: List[Dict[str, str]]) -> Dict[str, Any]:
|
| 1227 |
+
"""批量上传凭证文件到统一存储系统"""
|
| 1228 |
+
results = []
|
| 1229 |
+
success_count = 0
|
| 1230 |
+
|
| 1231 |
+
for file_data in files_data:
|
| 1232 |
+
filename = file_data.get("filename", "unknown.json")
|
| 1233 |
+
content = file_data.get("content", "")
|
| 1234 |
+
|
| 1235 |
+
result = await save_uploaded_credential(content, filename)
|
| 1236 |
+
result["filename"] = filename
|
| 1237 |
+
results.append(result)
|
| 1238 |
+
|
| 1239 |
+
if result["success"]:
|
| 1240 |
+
success_count += 1
|
| 1241 |
+
|
| 1242 |
+
return {"uploaded_count": success_count, "total_count": len(files_data), "results": results}
|
src/converter/anthropic2gemini.py
ADDED
|
@@ -0,0 +1,931 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 log import log
|
| 14 |
+
from src.converter.utils import merge_system_messages
|
| 15 |
+
|
| 16 |
+
from src.converter.thoughtSignature_fix import (
|
| 17 |
+
encode_tool_id_with_signature,
|
| 18 |
+
decode_tool_id_and_signature
|
| 19 |
+
)
|
| 20 |
+
|
| 21 |
+
DEFAULT_TEMPERATURE = 0.4
|
| 22 |
+
_DEBUG_TRUE = {"1", "true", "yes", "on"}
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
# ============================================================================
|
| 26 |
+
# 请求验证和提取
|
| 27 |
+
# ============================================================================
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def _anthropic_debug_enabled() -> bool:
|
| 31 |
+
"""检查是否启用 Anthropic 调试模式"""
|
| 32 |
+
return str(os.getenv("ANTHROPIC_DEBUG", "true")).strip().lower() in _DEBUG_TRUE
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def _is_non_whitespace_text(value: Any) -> bool:
|
| 36 |
+
"""
|
| 37 |
+
判断文本是否包含"非空白"内容。
|
| 38 |
+
|
| 39 |
+
说明:下游(Antigravity/Claude 兼容层)会对纯 text 内容块做校验:
|
| 40 |
+
- text 不能为空字符串
|
| 41 |
+
- text 不能仅由空白字符(空格/换行/制表等)组成
|
| 42 |
+
"""
|
| 43 |
+
if value is None:
|
| 44 |
+
return False
|
| 45 |
+
try:
|
| 46 |
+
return bool(str(value).strip())
|
| 47 |
+
except Exception:
|
| 48 |
+
return False
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def _remove_nulls_for_tool_input(value: Any) -> Any:
|
| 52 |
+
"""
|
| 53 |
+
递归移除 dict/list 中值为 null/None 的字段/元素。
|
| 54 |
+
|
| 55 |
+
背景:Roo/Kilo 在 Anthropic native tool 路径下,若收到 tool_use.input 中包含 null,
|
| 56 |
+
可能会把 null 当作真实入参执行(例如"在 null 中搜索")。
|
| 57 |
+
"""
|
| 58 |
+
if isinstance(value, dict):
|
| 59 |
+
cleaned: Dict[str, Any] = {}
|
| 60 |
+
for k, v in value.items():
|
| 61 |
+
if v is None:
|
| 62 |
+
continue
|
| 63 |
+
cleaned[k] = _remove_nulls_for_tool_input(v)
|
| 64 |
+
return cleaned
|
| 65 |
+
|
| 66 |
+
if isinstance(value, list):
|
| 67 |
+
cleaned_list = []
|
| 68 |
+
for item in value:
|
| 69 |
+
if item is None:
|
| 70 |
+
continue
|
| 71 |
+
cleaned_list.append(_remove_nulls_for_tool_input(item))
|
| 72 |
+
return cleaned_list
|
| 73 |
+
|
| 74 |
+
return value
|
| 75 |
+
|
| 76 |
+
# ============================================================================
|
| 77 |
+
# 2. JSON Schema 清理
|
| 78 |
+
# ============================================================================
|
| 79 |
+
|
| 80 |
+
def clean_json_schema(schema: Any) -> Any:
|
| 81 |
+
"""
|
| 82 |
+
清理 JSON Schema,移除下游不支持的字段,并把验证要求追加到 description。
|
| 83 |
+
"""
|
| 84 |
+
if not isinstance(schema, dict):
|
| 85 |
+
return schema
|
| 86 |
+
|
| 87 |
+
# 下游不支持的字段
|
| 88 |
+
unsupported_keys = {
|
| 89 |
+
"$schema", "$id", "$ref", "$defs", "definitions", "title",
|
| 90 |
+
"example", "examples", "readOnly", "writeOnly", "default",
|
| 91 |
+
"exclusiveMaximum", "exclusiveMinimum", "oneOf", "anyOf", "allOf",
|
| 92 |
+
"const", "additionalItems", "contains", "patternProperties",
|
| 93 |
+
"dependencies", "propertyNames", "if", "then", "else",
|
| 94 |
+
"contentEncoding", "contentMediaType",
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
validation_fields = {
|
| 98 |
+
"minLength": "minLength",
|
| 99 |
+
"maxLength": "maxLength",
|
| 100 |
+
"minimum": "minimum",
|
| 101 |
+
"maximum": "maximum",
|
| 102 |
+
"minItems": "minItems",
|
| 103 |
+
"maxItems": "maxItems",
|
| 104 |
+
}
|
| 105 |
+
fields_to_remove = {"additionalProperties"}
|
| 106 |
+
|
| 107 |
+
validations: List[str] = []
|
| 108 |
+
for field, label in validation_fields.items():
|
| 109 |
+
if field in schema:
|
| 110 |
+
validations.append(f"{label}: {schema[field]}")
|
| 111 |
+
|
| 112 |
+
cleaned: Dict[str, Any] = {}
|
| 113 |
+
for key, value in schema.items():
|
| 114 |
+
if key in unsupported_keys or key in fields_to_remove or key in validation_fields:
|
| 115 |
+
continue
|
| 116 |
+
|
| 117 |
+
if key == "type" and isinstance(value, list):
|
| 118 |
+
# type: ["string", "null"] -> type: "string", nullable: true
|
| 119 |
+
has_null = any(
|
| 120 |
+
isinstance(t, str) and t.strip() and t.strip().lower() == "null" for t in value
|
| 121 |
+
)
|
| 122 |
+
non_null_types = [
|
| 123 |
+
t.strip()
|
| 124 |
+
for t in value
|
| 125 |
+
if isinstance(t, str) and t.strip() and t.strip().lower() != "null"
|
| 126 |
+
]
|
| 127 |
+
|
| 128 |
+
cleaned[key] = non_null_types[0] if non_null_types else "string"
|
| 129 |
+
if has_null:
|
| 130 |
+
cleaned["nullable"] = True
|
| 131 |
+
continue
|
| 132 |
+
|
| 133 |
+
if key == "description" and validations:
|
| 134 |
+
cleaned[key] = f"{value} ({', '.join(validations)})"
|
| 135 |
+
elif isinstance(value, dict):
|
| 136 |
+
cleaned[key] = clean_json_schema(value)
|
| 137 |
+
elif isinstance(value, list):
|
| 138 |
+
cleaned[key] = [clean_json_schema(item) if isinstance(item, dict) else item for item in value]
|
| 139 |
+
else:
|
| 140 |
+
cleaned[key] = value
|
| 141 |
+
|
| 142 |
+
if validations and "description" not in cleaned:
|
| 143 |
+
cleaned["description"] = f"Validation: {', '.join(validations)}"
|
| 144 |
+
|
| 145 |
+
# 如果有 properties 但没有显式 type,则补齐为 object
|
| 146 |
+
if "properties" in cleaned and "type" not in cleaned:
|
| 147 |
+
cleaned["type"] = "object"
|
| 148 |
+
|
| 149 |
+
return cleaned
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
# ============================================================================
|
| 153 |
+
# 4. Tools 转换
|
| 154 |
+
# ============================================================================
|
| 155 |
+
|
| 156 |
+
def convert_tools(anthropic_tools: Optional[List[Dict[str, Any]]]) -> Optional[List[Dict[str, Any]]]:
|
| 157 |
+
"""
|
| 158 |
+
将 Anthropic tools[] 转换为下游 tools(functionDeclarations)结构。
|
| 159 |
+
"""
|
| 160 |
+
if not anthropic_tools:
|
| 161 |
+
return None
|
| 162 |
+
|
| 163 |
+
gemini_tools: List[Dict[str, Any]] = []
|
| 164 |
+
for tool in anthropic_tools:
|
| 165 |
+
name = tool.get("name", "nameless_function")
|
| 166 |
+
description = tool.get("description", "")
|
| 167 |
+
input_schema = tool.get("input_schema", {}) or {}
|
| 168 |
+
parameters = clean_json_schema(input_schema)
|
| 169 |
+
|
| 170 |
+
gemini_tools.append(
|
| 171 |
+
{
|
| 172 |
+
"functionDeclarations": [
|
| 173 |
+
{
|
| 174 |
+
"name": name,
|
| 175 |
+
"description": description,
|
| 176 |
+
"parameters": parameters,
|
| 177 |
+
}
|
| 178 |
+
]
|
| 179 |
+
}
|
| 180 |
+
)
|
| 181 |
+
|
| 182 |
+
return gemini_tools or None
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
# ============================================================================
|
| 186 |
+
# 5. Messages 转换
|
| 187 |
+
# ============================================================================
|
| 188 |
+
|
| 189 |
+
def _extract_tool_result_output(content: Any) -> str:
|
| 190 |
+
"""从 tool_result.content 中提取输出字符串"""
|
| 191 |
+
if isinstance(content, list):
|
| 192 |
+
if not content:
|
| 193 |
+
return ""
|
| 194 |
+
first = content[0]
|
| 195 |
+
if isinstance(first, dict) and first.get("type") == "text":
|
| 196 |
+
return str(first.get("text", ""))
|
| 197 |
+
return str(first)
|
| 198 |
+
if content is None:
|
| 199 |
+
return ""
|
| 200 |
+
return str(content)
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
def convert_messages_to_contents(
|
| 204 |
+
messages: List[Dict[str, Any]],
|
| 205 |
+
*,
|
| 206 |
+
include_thinking: bool = True
|
| 207 |
+
) -> List[Dict[str, Any]]:
|
| 208 |
+
"""
|
| 209 |
+
将 Anthropic messages[] 转换为下游 contents[](role: user/model, parts: [])。
|
| 210 |
+
|
| 211 |
+
Args:
|
| 212 |
+
messages: Anthropic 格式的消息列表
|
| 213 |
+
include_thinking: 是否包含 thinking 块
|
| 214 |
+
"""
|
| 215 |
+
contents: List[Dict[str, Any]] = []
|
| 216 |
+
|
| 217 |
+
# 第一遍:构建 tool_use_id -> name 的映射
|
| 218 |
+
tool_use_names: Dict[str, str] = {}
|
| 219 |
+
for msg in messages:
|
| 220 |
+
raw_content = msg.get("content", "")
|
| 221 |
+
if isinstance(raw_content, list):
|
| 222 |
+
for item in raw_content:
|
| 223 |
+
if isinstance(item, dict) and item.get("type") == "tool_use":
|
| 224 |
+
tool_id = item.get("id")
|
| 225 |
+
tool_name = item.get("name")
|
| 226 |
+
if tool_id and tool_name:
|
| 227 |
+
tool_use_names[str(tool_id)] = tool_name
|
| 228 |
+
|
| 229 |
+
for msg in messages:
|
| 230 |
+
role = msg.get("role", "user")
|
| 231 |
+
|
| 232 |
+
# system 消息已经由 merge_system_messages 处理,这里跳过
|
| 233 |
+
if role == "system":
|
| 234 |
+
continue
|
| 235 |
+
|
| 236 |
+
gemini_role = "model" if role == "assistant" else "user"
|
| 237 |
+
raw_content = msg.get("content", "")
|
| 238 |
+
|
| 239 |
+
parts: List[Dict[str, Any]] = []
|
| 240 |
+
if isinstance(raw_content, str):
|
| 241 |
+
if _is_non_whitespace_text(raw_content):
|
| 242 |
+
parts = [{"text": str(raw_content)}]
|
| 243 |
+
elif isinstance(raw_content, list):
|
| 244 |
+
for item in raw_content:
|
| 245 |
+
if not isinstance(item, dict):
|
| 246 |
+
if _is_non_whitespace_text(item):
|
| 247 |
+
parts.append({"text": str(item)})
|
| 248 |
+
continue
|
| 249 |
+
|
| 250 |
+
item_type = item.get("type")
|
| 251 |
+
if item_type == "thinking":
|
| 252 |
+
if not include_thinking:
|
| 253 |
+
continue
|
| 254 |
+
|
| 255 |
+
thinking_text = item.get("thinking", "")
|
| 256 |
+
if thinking_text is None:
|
| 257 |
+
thinking_text = ""
|
| 258 |
+
|
| 259 |
+
part: Dict[str, Any] = {
|
| 260 |
+
"text": str(thinking_text),
|
| 261 |
+
"thought": True,
|
| 262 |
+
}
|
| 263 |
+
|
| 264 |
+
# 如果有 signature 则添加
|
| 265 |
+
signature = item.get("signature")
|
| 266 |
+
if signature:
|
| 267 |
+
part["thoughtSignature"] = signature
|
| 268 |
+
|
| 269 |
+
parts.append(part)
|
| 270 |
+
elif item_type == "redacted_thinking":
|
| 271 |
+
if not include_thinking:
|
| 272 |
+
continue
|
| 273 |
+
|
| 274 |
+
thinking_text = item.get("thinking")
|
| 275 |
+
if thinking_text is None:
|
| 276 |
+
thinking_text = item.get("data", "")
|
| 277 |
+
|
| 278 |
+
part_dict: Dict[str, Any] = {
|
| 279 |
+
"text": str(thinking_text or ""),
|
| 280 |
+
"thought": True,
|
| 281 |
+
}
|
| 282 |
+
|
| 283 |
+
# 如果有 signature 则添加
|
| 284 |
+
signature = item.get("signature")
|
| 285 |
+
if signature:
|
| 286 |
+
part_dict["thoughtSignature"] = signature
|
| 287 |
+
|
| 288 |
+
parts.append(part_dict)
|
| 289 |
+
elif item_type == "text":
|
| 290 |
+
text = item.get("text", "")
|
| 291 |
+
if _is_non_whitespace_text(text):
|
| 292 |
+
parts.append({"text": str(text)})
|
| 293 |
+
elif item_type == "image":
|
| 294 |
+
source = item.get("source", {}) or {}
|
| 295 |
+
if source.get("type") == "base64":
|
| 296 |
+
parts.append(
|
| 297 |
+
{
|
| 298 |
+
"inlineData": {
|
| 299 |
+
"mimeType": source.get("media_type", "image/png"),
|
| 300 |
+
"data": source.get("data", ""),
|
| 301 |
+
}
|
| 302 |
+
}
|
| 303 |
+
)
|
| 304 |
+
elif item_type == "tool_use":
|
| 305 |
+
encoded_id = item.get("id") or ""
|
| 306 |
+
original_id, signature = decode_tool_id_and_signature(encoded_id)
|
| 307 |
+
|
| 308 |
+
fc_part: Dict[str, Any] = {
|
| 309 |
+
"functionCall": {
|
| 310 |
+
"id": original_id,
|
| 311 |
+
"name": item.get("name"),
|
| 312 |
+
"args": item.get("input", {}) or {},
|
| 313 |
+
}
|
| 314 |
+
}
|
| 315 |
+
|
| 316 |
+
# 如果提取到签名则添加
|
| 317 |
+
if signature:
|
| 318 |
+
fc_part["thoughtSignature"] = signature
|
| 319 |
+
|
| 320 |
+
parts.append(fc_part)
|
| 321 |
+
elif item_type == "tool_result":
|
| 322 |
+
output = _extract_tool_result_output(item.get("content"))
|
| 323 |
+
encoded_tool_use_id = item.get("tool_use_id") or ""
|
| 324 |
+
# 解码获取原始ID(functionResponse不需要签名)
|
| 325 |
+
original_tool_use_id, _ = decode_tool_id_and_signature(encoded_tool_use_id)
|
| 326 |
+
|
| 327 |
+
# 从 tool_result 获取 name,如果没有则从映射中查找
|
| 328 |
+
func_name = item.get("name")
|
| 329 |
+
if not func_name and encoded_tool_use_id:
|
| 330 |
+
# 使用编码ID查找,因为映射中存储的是编码ID
|
| 331 |
+
func_name = tool_use_names.get(str(encoded_tool_use_id))
|
| 332 |
+
if not func_name:
|
| 333 |
+
func_name = "unknown_function"
|
| 334 |
+
parts.append(
|
| 335 |
+
{
|
| 336 |
+
"functionResponse": {
|
| 337 |
+
"id": original_tool_use_id, # 使用解码后的ID以匹配functionCall
|
| 338 |
+
"name": func_name,
|
| 339 |
+
"response": {"output": output},
|
| 340 |
+
}
|
| 341 |
+
}
|
| 342 |
+
)
|
| 343 |
+
else:
|
| 344 |
+
parts.append({"text": json.dumps(item, ensure_ascii=False)})
|
| 345 |
+
else:
|
| 346 |
+
if _is_non_whitespace_text(raw_content):
|
| 347 |
+
parts = [{"text": str(raw_content)}]
|
| 348 |
+
|
| 349 |
+
if not parts:
|
| 350 |
+
continue
|
| 351 |
+
|
| 352 |
+
contents.append({"role": gemini_role, "parts": parts})
|
| 353 |
+
|
| 354 |
+
return contents
|
| 355 |
+
|
| 356 |
+
|
| 357 |
+
def reorganize_tool_messages(contents: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
| 358 |
+
"""
|
| 359 |
+
重新组织消息,满足 tool_use/tool_result 约束。
|
| 360 |
+
"""
|
| 361 |
+
tool_results: Dict[str, Dict[str, Any]] = {}
|
| 362 |
+
|
| 363 |
+
for msg in contents:
|
| 364 |
+
for part in msg.get("parts", []) or []:
|
| 365 |
+
if isinstance(part, dict) and "functionResponse" in part:
|
| 366 |
+
tool_id = (part.get("functionResponse") or {}).get("id")
|
| 367 |
+
if tool_id:
|
| 368 |
+
tool_results[str(tool_id)] = part
|
| 369 |
+
|
| 370 |
+
flattened: List[Dict[str, Any]] = []
|
| 371 |
+
for msg in contents:
|
| 372 |
+
role = msg.get("role")
|
| 373 |
+
for part in msg.get("parts", []) or []:
|
| 374 |
+
flattened.append({"role": role, "parts": [part]})
|
| 375 |
+
|
| 376 |
+
new_contents: List[Dict[str, Any]] = []
|
| 377 |
+
i = 0
|
| 378 |
+
while i < len(flattened):
|
| 379 |
+
msg = flattened[i]
|
| 380 |
+
part = msg["parts"][0]
|
| 381 |
+
|
| 382 |
+
if isinstance(part, dict) and "functionResponse" in part:
|
| 383 |
+
i += 1
|
| 384 |
+
continue
|
| 385 |
+
|
| 386 |
+
if isinstance(part, dict) and "functionCall" in part:
|
| 387 |
+
tool_id = (part.get("functionCall") or {}).get("id")
|
| 388 |
+
new_contents.append({"role": "model", "parts": [part]})
|
| 389 |
+
|
| 390 |
+
if tool_id is not None and str(tool_id) in tool_results:
|
| 391 |
+
new_contents.append({"role": "user", "parts": [tool_results[str(tool_id)]]})
|
| 392 |
+
|
| 393 |
+
i += 1
|
| 394 |
+
continue
|
| 395 |
+
|
| 396 |
+
new_contents.append(msg)
|
| 397 |
+
i += 1
|
| 398 |
+
|
| 399 |
+
return new_contents
|
| 400 |
+
|
| 401 |
+
|
| 402 |
+
# ============================================================================
|
| 403 |
+
# 7. Generation Config 构建
|
| 404 |
+
# ============================================================================
|
| 405 |
+
|
| 406 |
+
def build_generation_config(payload: Dict[str, Any]) -> Dict[str, Any]:
|
| 407 |
+
"""
|
| 408 |
+
根据 Anthropic Messages 请求构造下游 generationConfig。
|
| 409 |
+
|
| 410 |
+
Returns:
|
| 411 |
+
generation_config: 生成配置字典
|
| 412 |
+
"""
|
| 413 |
+
config: Dict[str, Any] = {
|
| 414 |
+
"topP": 1,
|
| 415 |
+
"candidateCount": 1,
|
| 416 |
+
"stopSequences": [
|
| 417 |
+
"<|user|>",
|
| 418 |
+
"<|bot|>",
|
| 419 |
+
"<|context_request|>",
|
| 420 |
+
"<|endoftext|>",
|
| 421 |
+
"<|end_of_turn|>",
|
| 422 |
+
],
|
| 423 |
+
}
|
| 424 |
+
|
| 425 |
+
temperature = payload.get("temperature", None)
|
| 426 |
+
config["temperature"] = DEFAULT_TEMPERATURE if temperature is None else temperature
|
| 427 |
+
|
| 428 |
+
top_p = payload.get("top_p", None)
|
| 429 |
+
if top_p is not None:
|
| 430 |
+
config["topP"] = top_p
|
| 431 |
+
|
| 432 |
+
top_k = payload.get("top_k", None)
|
| 433 |
+
if top_k is not None:
|
| 434 |
+
config["topK"] = top_k
|
| 435 |
+
|
| 436 |
+
max_tokens = payload.get("max_tokens")
|
| 437 |
+
if max_tokens is not None:
|
| 438 |
+
config["maxOutputTokens"] = max_tokens
|
| 439 |
+
|
| 440 |
+
stop_sequences = payload.get("stop_sequences")
|
| 441 |
+
if isinstance(stop_sequences, list) and stop_sequences:
|
| 442 |
+
config["stopSequences"] = config["stopSequences"] + [str(s) for s in stop_sequences]
|
| 443 |
+
|
| 444 |
+
return config
|
| 445 |
+
|
| 446 |
+
|
| 447 |
+
# ============================================================================
|
| 448 |
+
# 8. 主要转换函数
|
| 449 |
+
# ============================================================================
|
| 450 |
+
|
| 451 |
+
async def anthropic_to_gemini_request(payload: Dict[str, Any]) -> Dict[str, Any]:
|
| 452 |
+
"""
|
| 453 |
+
将 Anthropic 格式请求体转换为 Gemini 格式请求体
|
| 454 |
+
|
| 455 |
+
注意: 此函数只负责基础转换,不包含 normalize_gemini_request 中的处理
|
| 456 |
+
(如 thinking config 自动设置、search tools、参数范围限制等)
|
| 457 |
+
|
| 458 |
+
Args:
|
| 459 |
+
payload: Anthropic 格式的请求体字典
|
| 460 |
+
|
| 461 |
+
Returns:
|
| 462 |
+
Gemini 格式的请求体字典,包含:
|
| 463 |
+
- contents: 转换后的消息内容
|
| 464 |
+
- generationConfig: 生成配置
|
| 465 |
+
- systemInstruction: 系统指令 (如果有)
|
| 466 |
+
- tools: 工具定义 (如果有)
|
| 467 |
+
"""
|
| 468 |
+
# 处理连续的system消息(兼容性模式)
|
| 469 |
+
payload = await merge_system_messages(payload)
|
| 470 |
+
|
| 471 |
+
# 提取和转换基础信息
|
| 472 |
+
messages = payload.get("messages") or []
|
| 473 |
+
if not isinstance(messages, list):
|
| 474 |
+
messages = []
|
| 475 |
+
|
| 476 |
+
# 构建生成配置
|
| 477 |
+
generation_config = build_generation_config(payload)
|
| 478 |
+
|
| 479 |
+
# 转换消息内容(始终包含thinking块,由响应端处理)
|
| 480 |
+
contents = convert_messages_to_contents(messages, include_thinking=True)
|
| 481 |
+
contents = reorganize_tool_messages(contents)
|
| 482 |
+
|
| 483 |
+
# 转换工具
|
| 484 |
+
tools = convert_tools(payload.get("tools"))
|
| 485 |
+
|
| 486 |
+
# 构建基础请求数据
|
| 487 |
+
gemini_request = {
|
| 488 |
+
"contents": contents,
|
| 489 |
+
"generationConfig": generation_config,
|
| 490 |
+
}
|
| 491 |
+
|
| 492 |
+
# 如果 merge_system_messages 已经添加了 systemInstruction,使用它
|
| 493 |
+
if "systemInstruction" in payload:
|
| 494 |
+
gemini_request["systemInstruction"] = payload["systemInstruction"]
|
| 495 |
+
|
| 496 |
+
if tools:
|
| 497 |
+
gemini_request["tools"] = tools
|
| 498 |
+
|
| 499 |
+
return gemini_request
|
| 500 |
+
|
| 501 |
+
|
| 502 |
+
def gemini_to_anthropic_response(
|
| 503 |
+
gemini_response: Dict[str, Any],
|
| 504 |
+
model: str,
|
| 505 |
+
status_code: int = 200
|
| 506 |
+
) -> Dict[str, Any]:
|
| 507 |
+
"""
|
| 508 |
+
将 Gemini 格式非流式响应转换为 Anthropic 格式非流式响应
|
| 509 |
+
|
| 510 |
+
注意: 如果收到的不是 200 开头的响应体,不做任何处理,直接转发
|
| 511 |
+
|
| 512 |
+
Args:
|
| 513 |
+
gemini_response: Gemini 格式的响应体字典
|
| 514 |
+
model: 模型名称
|
| 515 |
+
status_code: HTTP 状态码 (默认 200)
|
| 516 |
+
|
| 517 |
+
Returns:
|
| 518 |
+
Anthropic 格式的响应体字典,或原始响应 (如果状态码不是 2xx)
|
| 519 |
+
"""
|
| 520 |
+
# 非 2xx 状态码直接返回原始响应
|
| 521 |
+
if not (200 <= status_code < 300):
|
| 522 |
+
return gemini_response
|
| 523 |
+
|
| 524 |
+
# 处理 GeminiCLI 的 response 包装格式
|
| 525 |
+
if "response" in gemini_response:
|
| 526 |
+
response_data = gemini_response["response"]
|
| 527 |
+
else:
|
| 528 |
+
response_data = gemini_response
|
| 529 |
+
|
| 530 |
+
# 提取候选结果
|
| 531 |
+
candidate = response_data.get("candidates", [{}])[0] or {}
|
| 532 |
+
parts = candidate.get("content", {}).get("parts", []) or []
|
| 533 |
+
|
| 534 |
+
# 获取 usage metadata
|
| 535 |
+
usage_metadata = {}
|
| 536 |
+
if "usageMetadata" in response_data:
|
| 537 |
+
usage_metadata = response_data["usageMetadata"]
|
| 538 |
+
elif "usageMetadata" in candidate:
|
| 539 |
+
usage_metadata = candidate["usageMetadata"]
|
| 540 |
+
|
| 541 |
+
# 转换内容块
|
| 542 |
+
content = []
|
| 543 |
+
has_tool_use = False
|
| 544 |
+
|
| 545 |
+
for part in parts:
|
| 546 |
+
if not isinstance(part, dict):
|
| 547 |
+
continue
|
| 548 |
+
|
| 549 |
+
# 处理 thinking 块
|
| 550 |
+
if part.get("thought") is True:
|
| 551 |
+
block: Dict[str, Any] = {"type": "thinking", "thinking": part.get("text", "")}
|
| 552 |
+
signature = part.get("thoughtSignature")
|
| 553 |
+
if signature:
|
| 554 |
+
block["signature"] = signature
|
| 555 |
+
content.append(block)
|
| 556 |
+
continue
|
| 557 |
+
|
| 558 |
+
# 处理文本块
|
| 559 |
+
if "text" in part:
|
| 560 |
+
content.append({"type": "text", "text": part.get("text", "")})
|
| 561 |
+
continue
|
| 562 |
+
|
| 563 |
+
# 处理工具调用
|
| 564 |
+
if "functionCall" in part:
|
| 565 |
+
has_tool_use = True
|
| 566 |
+
fc = part.get("functionCall", {}) or {}
|
| 567 |
+
original_id = fc.get("id") or f"toolu_{uuid.uuid4().hex}"
|
| 568 |
+
signature = part.get("thoughtSignature")
|
| 569 |
+
encoded_id = encode_tool_id_with_signature(original_id, signature)
|
| 570 |
+
content.append(
|
| 571 |
+
{
|
| 572 |
+
"type": "tool_use",
|
| 573 |
+
"id": encoded_id,
|
| 574 |
+
"name": fc.get("name") or "",
|
| 575 |
+
"input": _remove_nulls_for_tool_input(fc.get("args", {}) or {}),
|
| 576 |
+
}
|
| 577 |
+
)
|
| 578 |
+
continue
|
| 579 |
+
|
| 580 |
+
# 处理图片
|
| 581 |
+
if "inlineData" in part:
|
| 582 |
+
inline = part.get("inlineData", {}) or {}
|
| 583 |
+
content.append(
|
| 584 |
+
{
|
| 585 |
+
"type": "image",
|
| 586 |
+
"source": {
|
| 587 |
+
"type": "base64",
|
| 588 |
+
"media_type": inline.get("mimeType", "image/png"),
|
| 589 |
+
"data": inline.get("data", ""),
|
| 590 |
+
},
|
| 591 |
+
}
|
| 592 |
+
)
|
| 593 |
+
continue
|
| 594 |
+
|
| 595 |
+
# 确定停止原因
|
| 596 |
+
finish_reason = candidate.get("finishReason")
|
| 597 |
+
stop_reason = "tool_use" if has_tool_use else "end_turn"
|
| 598 |
+
if finish_reason == "MAX_TOKENS" and not has_tool_use:
|
| 599 |
+
stop_reason = "max_tokens"
|
| 600 |
+
|
| 601 |
+
# 提取 token 使用情况
|
| 602 |
+
input_tokens = usage_metadata.get("promptTokenCount", 0) if isinstance(usage_metadata, dict) else 0
|
| 603 |
+
output_tokens = usage_metadata.get("candidatesTokenCount", 0) if isinstance(usage_metadata, dict) else 0
|
| 604 |
+
|
| 605 |
+
# 构建 Anthropic 响应
|
| 606 |
+
message_id = f"msg_{uuid.uuid4().hex}"
|
| 607 |
+
|
| 608 |
+
return {
|
| 609 |
+
"id": message_id,
|
| 610 |
+
"type": "message",
|
| 611 |
+
"role": "assistant",
|
| 612 |
+
"model": model,
|
| 613 |
+
"content": content,
|
| 614 |
+
"stop_reason": stop_reason,
|
| 615 |
+
"stop_sequence": None,
|
| 616 |
+
"usage": {
|
| 617 |
+
"input_tokens": int(input_tokens or 0),
|
| 618 |
+
"output_tokens": int(output_tokens or 0),
|
| 619 |
+
},
|
| 620 |
+
}
|
| 621 |
+
|
| 622 |
+
|
| 623 |
+
async def gemini_stream_to_anthropic_stream(
|
| 624 |
+
gemini_stream: AsyncIterator[bytes],
|
| 625 |
+
model: str,
|
| 626 |
+
status_code: int = 200
|
| 627 |
+
) -> AsyncIterator[bytes]:
|
| 628 |
+
"""
|
| 629 |
+
将 Gemini 格式流式响应转换为 Anthropic SSE 格式流式响应
|
| 630 |
+
|
| 631 |
+
注意: 如果收到的不是 200 开头的响应体,不做任何处理,直接转发
|
| 632 |
+
|
| 633 |
+
Args:
|
| 634 |
+
gemini_stream: Gemini 格式的流式响应 (bytes 迭代器)
|
| 635 |
+
model: 模型名称
|
| 636 |
+
status_code: HTTP 状态码 (默认 200)
|
| 637 |
+
|
| 638 |
+
Yields:
|
| 639 |
+
Anthropic SSE 格式的响应块 (bytes)
|
| 640 |
+
"""
|
| 641 |
+
# 非 2xx 状态码直接转发原始流
|
| 642 |
+
if not (200 <= status_code < 300):
|
| 643 |
+
async for chunk in gemini_stream:
|
| 644 |
+
yield chunk
|
| 645 |
+
return
|
| 646 |
+
|
| 647 |
+
# 初始化状态
|
| 648 |
+
message_id = f"msg_{uuid.uuid4().hex}"
|
| 649 |
+
message_start_sent = False
|
| 650 |
+
current_block_type: Optional[str] = None
|
| 651 |
+
current_block_index = -1
|
| 652 |
+
current_thinking_signature: Optional[str] = None
|
| 653 |
+
has_tool_use = False
|
| 654 |
+
input_tokens = 0
|
| 655 |
+
output_tokens = 0
|
| 656 |
+
finish_reason: Optional[str] = None
|
| 657 |
+
|
| 658 |
+
def _sse_event(event: str, data: Dict[str, Any]) -> bytes:
|
| 659 |
+
"""生成 SSE 事件"""
|
| 660 |
+
payload = json.dumps(data, ensure_ascii=False, separators=(",", ":"))
|
| 661 |
+
return f"event: {event}\ndata: {payload}\n\n".encode("utf-8")
|
| 662 |
+
|
| 663 |
+
def _close_block() -> Optional[bytes]:
|
| 664 |
+
"""关闭当前内容块"""
|
| 665 |
+
nonlocal current_block_type
|
| 666 |
+
if current_block_type is None:
|
| 667 |
+
return None
|
| 668 |
+
event = _sse_event(
|
| 669 |
+
"content_block_stop",
|
| 670 |
+
{"type": "content_block_stop", "index": current_block_index},
|
| 671 |
+
)
|
| 672 |
+
current_block_type = None
|
| 673 |
+
return event
|
| 674 |
+
|
| 675 |
+
# 处理流式数据
|
| 676 |
+
try:
|
| 677 |
+
async for chunk in gemini_stream:
|
| 678 |
+
# 记录接收到的原始chunk
|
| 679 |
+
log.debug(f"[GEMINI_TO_ANTHROPIC] Raw chunk: {chunk[:200] if chunk else b''}")
|
| 680 |
+
|
| 681 |
+
# 解析 Gemini 流式块
|
| 682 |
+
if not chunk or not chunk.startswith(b"data: "):
|
| 683 |
+
log.debug(f"[GEMINI_TO_ANTHROPIC] Skipping chunk (not SSE format or empty)")
|
| 684 |
+
continue
|
| 685 |
+
|
| 686 |
+
raw = chunk[6:].strip()
|
| 687 |
+
if raw == b"[DONE]":
|
| 688 |
+
log.debug(f"[GEMINI_TO_ANTHROPIC] Received [DONE] marker")
|
| 689 |
+
break
|
| 690 |
+
|
| 691 |
+
log.debug(f"[GEMINI_TO_ANTHROPIC] Parsing JSON: {raw[:200]}")
|
| 692 |
+
|
| 693 |
+
try:
|
| 694 |
+
data = json.loads(raw.decode('utf-8', errors='ignore'))
|
| 695 |
+
log.debug(f"[GEMINI_TO_ANTHROPIC] Parsed data: {json.dumps(data, ensure_ascii=False)[:300]}")
|
| 696 |
+
except Exception as e:
|
| 697 |
+
log.warning(f"[GEMINI_TO_ANTHROPIC] JSON parse error: {e}")
|
| 698 |
+
continue
|
| 699 |
+
|
| 700 |
+
# 处理 GeminiCLI 的 response 包装格式
|
| 701 |
+
if "response" in data:
|
| 702 |
+
response = data["response"]
|
| 703 |
+
else:
|
| 704 |
+
response = data
|
| 705 |
+
|
| 706 |
+
candidate = (response.get("candidates", []) or [{}])[0] or {}
|
| 707 |
+
parts = (candidate.get("content", {}) or {}).get("parts", []) or []
|
| 708 |
+
|
| 709 |
+
# 更新 usage metadata
|
| 710 |
+
if "usageMetadata" in response:
|
| 711 |
+
usage = response["usageMetadata"]
|
| 712 |
+
if isinstance(usage, dict):
|
| 713 |
+
if "promptTokenCount" in usage:
|
| 714 |
+
input_tokens = int(usage.get("promptTokenCount", 0) or 0)
|
| 715 |
+
if "candidatesTokenCount" in usage:
|
| 716 |
+
output_tokens = int(usage.get("candidatesTokenCount", 0) or 0)
|
| 717 |
+
|
| 718 |
+
# 发送 message_start(仅一次)
|
| 719 |
+
if not message_start_sent:
|
| 720 |
+
message_start_sent = True
|
| 721 |
+
yield _sse_event(
|
| 722 |
+
"message_start",
|
| 723 |
+
{
|
| 724 |
+
"type": "message_start",
|
| 725 |
+
"message": {
|
| 726 |
+
"id": message_id,
|
| 727 |
+
"type": "message",
|
| 728 |
+
"role": "assistant",
|
| 729 |
+
"model": model,
|
| 730 |
+
"content": [],
|
| 731 |
+
"stop_reason": None,
|
| 732 |
+
"stop_sequence": None,
|
| 733 |
+
"usage": {"input_tokens": 0, "output_tokens": 0},
|
| 734 |
+
},
|
| 735 |
+
},
|
| 736 |
+
)
|
| 737 |
+
|
| 738 |
+
# 处理各种 parts
|
| 739 |
+
for part in parts:
|
| 740 |
+
if not isinstance(part, dict):
|
| 741 |
+
continue
|
| 742 |
+
|
| 743 |
+
# 处理 thinking 块
|
| 744 |
+
if part.get("thought") is True:
|
| 745 |
+
if current_block_type != "thinking":
|
| 746 |
+
close_evt = _close_block()
|
| 747 |
+
if close_evt:
|
| 748 |
+
yield close_evt
|
| 749 |
+
|
| 750 |
+
current_block_index += 1
|
| 751 |
+
current_block_type = "thinking"
|
| 752 |
+
signature = part.get("thoughtSignature")
|
| 753 |
+
current_thinking_signature = signature
|
| 754 |
+
|
| 755 |
+
block: Dict[str, Any] = {"type": "thinking", "thinking": ""}
|
| 756 |
+
if signature:
|
| 757 |
+
block["signature"] = signature
|
| 758 |
+
|
| 759 |
+
yield _sse_event(
|
| 760 |
+
"content_block_start",
|
| 761 |
+
{
|
| 762 |
+
"type": "content_block_start",
|
| 763 |
+
"index": current_block_index,
|
| 764 |
+
"content_block": block,
|
| 765 |
+
},
|
| 766 |
+
)
|
| 767 |
+
|
| 768 |
+
thinking_text = part.get("text", "")
|
| 769 |
+
if thinking_text:
|
| 770 |
+
yield _sse_event(
|
| 771 |
+
"content_block_delta",
|
| 772 |
+
{
|
| 773 |
+
"type": "content_block_delta",
|
| 774 |
+
"index": current_block_index,
|
| 775 |
+
"delta": {"type": "thinking_delta", "thinking": thinking_text},
|
| 776 |
+
},
|
| 777 |
+
)
|
| 778 |
+
continue
|
| 779 |
+
|
| 780 |
+
# 处理文本块
|
| 781 |
+
if "text" in part:
|
| 782 |
+
text = part.get("text", "")
|
| 783 |
+
if isinstance(text, str) and not text.strip():
|
| 784 |
+
continue
|
| 785 |
+
|
| 786 |
+
if current_block_type != "text":
|
| 787 |
+
close_evt = _close_block()
|
| 788 |
+
if close_evt:
|
| 789 |
+
yield close_evt
|
| 790 |
+
|
| 791 |
+
current_block_index += 1
|
| 792 |
+
current_block_type = "text"
|
| 793 |
+
|
| 794 |
+
yield _sse_event(
|
| 795 |
+
"content_block_start",
|
| 796 |
+
{
|
| 797 |
+
"type": "content_block_start",
|
| 798 |
+
"index": current_block_index,
|
| 799 |
+
"content_block": {"type": "text", "text": ""},
|
| 800 |
+
},
|
| 801 |
+
)
|
| 802 |
+
|
| 803 |
+
if text:
|
| 804 |
+
yield _sse_event(
|
| 805 |
+
"content_block_delta",
|
| 806 |
+
{
|
| 807 |
+
"type": "content_block_delta",
|
| 808 |
+
"index": current_block_index,
|
| 809 |
+
"delta": {"type": "text_delta", "text": text},
|
| 810 |
+
},
|
| 811 |
+
)
|
| 812 |
+
continue
|
| 813 |
+
|
| 814 |
+
# 处理工具调用
|
| 815 |
+
if "functionCall" in part:
|
| 816 |
+
close_evt = _close_block()
|
| 817 |
+
if close_evt:
|
| 818 |
+
yield close_evt
|
| 819 |
+
|
| 820 |
+
has_tool_use = True
|
| 821 |
+
fc = part.get("functionCall", {}) or {}
|
| 822 |
+
original_id = fc.get("id") or f"toolu_{uuid.uuid4().hex}"
|
| 823 |
+
signature = part.get("thoughtSignature")
|
| 824 |
+
tool_id = encode_tool_id_with_signature(original_id, signature)
|
| 825 |
+
tool_name = fc.get("name") or ""
|
| 826 |
+
tool_args = _remove_nulls_for_tool_input(fc.get("args", {}) or {})
|
| 827 |
+
|
| 828 |
+
if _anthropic_debug_enabled():
|
| 829 |
+
log.info(
|
| 830 |
+
f"[ANTHROPIC][tool_use] 处理工具调用: name={tool_name}, "
|
| 831 |
+
f"id={tool_id}, has_signature={signature is not None}"
|
| 832 |
+
)
|
| 833 |
+
|
| 834 |
+
current_block_index += 1
|
| 835 |
+
# 注意:工具调用不设��� current_block_type,因为它是独立完整的块
|
| 836 |
+
|
| 837 |
+
yield _sse_event(
|
| 838 |
+
"content_block_start",
|
| 839 |
+
{
|
| 840 |
+
"type": "content_block_start",
|
| 841 |
+
"index": current_block_index,
|
| 842 |
+
"content_block": {
|
| 843 |
+
"type": "tool_use",
|
| 844 |
+
"id": tool_id,
|
| 845 |
+
"name": tool_name,
|
| 846 |
+
"input": {},
|
| 847 |
+
},
|
| 848 |
+
},
|
| 849 |
+
)
|
| 850 |
+
|
| 851 |
+
input_json = json.dumps(tool_args, ensure_ascii=False, separators=(",", ":"))
|
| 852 |
+
yield _sse_event(
|
| 853 |
+
"content_block_delta",
|
| 854 |
+
{
|
| 855 |
+
"type": "content_block_delta",
|
| 856 |
+
"index": current_block_index,
|
| 857 |
+
"delta": {"type": "input_json_delta", "partial_json": input_json},
|
| 858 |
+
},
|
| 859 |
+
)
|
| 860 |
+
|
| 861 |
+
yield _sse_event(
|
| 862 |
+
"content_block_stop",
|
| 863 |
+
{"type": "content_block_stop", "index": current_block_index},
|
| 864 |
+
)
|
| 865 |
+
# 工具调用块已完全关闭,current_block_type 保持为 None
|
| 866 |
+
|
| 867 |
+
if _anthropic_debug_enabled():
|
| 868 |
+
log.info(f"[ANTHROPIC][tool_use] 工具调用块已关闭: index={current_block_index}")
|
| 869 |
+
|
| 870 |
+
continue
|
| 871 |
+
|
| 872 |
+
# 检查是否结束
|
| 873 |
+
if candidate.get("finishReason"):
|
| 874 |
+
finish_reason = candidate.get("finishReason")
|
| 875 |
+
break
|
| 876 |
+
|
| 877 |
+
# 关闭最后的内容块
|
| 878 |
+
close_evt = _close_block()
|
| 879 |
+
if close_evt:
|
| 880 |
+
yield close_evt
|
| 881 |
+
|
| 882 |
+
# 确定停止原因
|
| 883 |
+
stop_reason = "tool_use" if has_tool_use else "end_turn"
|
| 884 |
+
if finish_reason == "MAX_TOKENS" and not has_tool_use:
|
| 885 |
+
stop_reason = "max_tokens"
|
| 886 |
+
|
| 887 |
+
if _anthropic_debug_enabled():
|
| 888 |
+
log.info(
|
| 889 |
+
f"[ANTHROPIC][stream_end] 流式结束: stop_reason={stop_reason}, "
|
| 890 |
+
f"has_tool_use={has_tool_use}, finish_reason={finish_reason}, "
|
| 891 |
+
f"input_tokens={input_tokens}, output_tokens={output_tokens}"
|
| 892 |
+
)
|
| 893 |
+
|
| 894 |
+
# 发送 message_delta 和 message_stop
|
| 895 |
+
yield _sse_event(
|
| 896 |
+
"message_delta",
|
| 897 |
+
{
|
| 898 |
+
"type": "message_delta",
|
| 899 |
+
"delta": {"stop_reason": stop_reason, "stop_sequence": None},
|
| 900 |
+
"usage": {
|
| 901 |
+
"output_tokens": output_tokens,
|
| 902 |
+
},
|
| 903 |
+
},
|
| 904 |
+
)
|
| 905 |
+
|
| 906 |
+
yield _sse_event("message_stop", {"type": "message_stop"})
|
| 907 |
+
|
| 908 |
+
except Exception as e:
|
| 909 |
+
log.error(f"[ANTHROPIC] 流式转换失败: {e}")
|
| 910 |
+
# 发送错误事件
|
| 911 |
+
if not message_start_sent:
|
| 912 |
+
yield _sse_event(
|
| 913 |
+
"message_start",
|
| 914 |
+
{
|
| 915 |
+
"type": "message_start",
|
| 916 |
+
"message": {
|
| 917 |
+
"id": message_id,
|
| 918 |
+
"type": "message",
|
| 919 |
+
"role": "assistant",
|
| 920 |
+
"model": model,
|
| 921 |
+
"content": [],
|
| 922 |
+
"stop_reason": None,
|
| 923 |
+
"stop_sequence": None,
|
| 924 |
+
"usage": {"input_tokens": 0, "output_tokens": 0},
|
| 925 |
+
},
|
| 926 |
+
},
|
| 927 |
+
)
|
| 928 |
+
yield _sse_event(
|
| 929 |
+
"error",
|
| 930 |
+
{"type": "error", "error": {"type": "api_error", "message": str(e)}},
|
| 931 |
+
)
|
src/converter/anti_truncation.py
ADDED
|
@@ -0,0 +1,699 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
):
|
| 193 |
+
self.original_request_func = original_request_func
|
| 194 |
+
self.base_payload = payload.copy()
|
| 195 |
+
self.max_attempts = max_attempts
|
| 196 |
+
# 使用 StringIO 避免字符串拼接的内存问题
|
| 197 |
+
self.collected_content = io.StringIO()
|
| 198 |
+
self.current_attempt = 0
|
| 199 |
+
|
| 200 |
+
def _get_collected_text(self) -> str:
|
| 201 |
+
"""获取收集的文本内容"""
|
| 202 |
+
return self.collected_content.getvalue()
|
| 203 |
+
|
| 204 |
+
def _append_content(self, content: str):
|
| 205 |
+
"""追加内容到收集器"""
|
| 206 |
+
if content:
|
| 207 |
+
self.collected_content.write(content)
|
| 208 |
+
|
| 209 |
+
def _clear_content(self):
|
| 210 |
+
"""清空收集的内容,释放内存"""
|
| 211 |
+
self.collected_content.close()
|
| 212 |
+
self.collected_content = io.StringIO()
|
| 213 |
+
|
| 214 |
+
async def process_stream(self) -> AsyncGenerator[bytes, None]:
|
| 215 |
+
"""处理流式响应,检测并处理截断"""
|
| 216 |
+
|
| 217 |
+
while self.current_attempt < self.max_attempts:
|
| 218 |
+
self.current_attempt += 1
|
| 219 |
+
|
| 220 |
+
# 构建当前请求payload
|
| 221 |
+
current_payload = self._build_current_payload()
|
| 222 |
+
|
| 223 |
+
log.debug(f"Anti-truncation attempt {self.current_attempt}/{self.max_attempts}")
|
| 224 |
+
|
| 225 |
+
# 发送请求
|
| 226 |
+
try:
|
| 227 |
+
response = await self.original_request_func(current_payload)
|
| 228 |
+
|
| 229 |
+
if not isinstance(response, StreamingResponse):
|
| 230 |
+
# 非流式响应,直接处理
|
| 231 |
+
yield await self._handle_non_streaming_response(response)
|
| 232 |
+
return
|
| 233 |
+
|
| 234 |
+
# 处理流式响应(按行处理)
|
| 235 |
+
chunk_buffer = io.StringIO() # 使用 StringIO 缓存当前轮次的chunk
|
| 236 |
+
found_done_marker = False
|
| 237 |
+
|
| 238 |
+
async for line in response.body_iterator:
|
| 239 |
+
if not line:
|
| 240 |
+
yield line
|
| 241 |
+
continue
|
| 242 |
+
|
| 243 |
+
# 处理 bytes 类型的流式数据
|
| 244 |
+
if isinstance(line, bytes):
|
| 245 |
+
# 解码 bytes 为字符串
|
| 246 |
+
line_str = line.decode('utf-8', errors='ignore').strip()
|
| 247 |
+
else:
|
| 248 |
+
line_str = str(line).strip()
|
| 249 |
+
|
| 250 |
+
# 跳过空行
|
| 251 |
+
if not line_str:
|
| 252 |
+
yield line
|
| 253 |
+
continue
|
| 254 |
+
|
| 255 |
+
# 处理 SSE 格式的数据行
|
| 256 |
+
if line_str.startswith("data: "):
|
| 257 |
+
payload_str = line_str[6:] # 去掉 "data: " 前缀
|
| 258 |
+
|
| 259 |
+
# 检查是否是 [DONE] 标记
|
| 260 |
+
if payload_str.strip() == "[DONE]":
|
| 261 |
+
if found_done_marker:
|
| 262 |
+
log.info("Anti-truncation: Found [done] marker, output complete")
|
| 263 |
+
yield line
|
| 264 |
+
# 清理内存
|
| 265 |
+
chunk_buffer.close()
|
| 266 |
+
self._clear_content()
|
| 267 |
+
return
|
| 268 |
+
else:
|
| 269 |
+
log.warning("Anti-truncation: Stream ended without [done] marker")
|
| 270 |
+
# 不发送[DONE],准备继续
|
| 271 |
+
break
|
| 272 |
+
|
| 273 |
+
# 尝试解析 JSON 数据
|
| 274 |
+
try:
|
| 275 |
+
data = json.loads(payload_str)
|
| 276 |
+
content = self._extract_content_from_chunk(data)
|
| 277 |
+
|
| 278 |
+
log.debug(f"Anti-truncation: Extracted content: {repr(content[:100] if content else '')}")
|
| 279 |
+
|
| 280 |
+
if content:
|
| 281 |
+
chunk_buffer.write(content)
|
| 282 |
+
|
| 283 |
+
# 检查是否包含done标记
|
| 284 |
+
has_marker = self._check_done_marker_in_chunk_content(content)
|
| 285 |
+
log.debug(f"Anti-truncation: Check done marker result: {has_marker}, DONE_MARKER='{DONE_MARKER}'")
|
| 286 |
+
if has_marker:
|
| 287 |
+
found_done_marker = True
|
| 288 |
+
log.debug(f"Anti-truncation: Found [done] marker in chunk, content: {content[:200]}")
|
| 289 |
+
|
| 290 |
+
# 清理行中的[done]标记后再发送
|
| 291 |
+
cleaned_line = self._remove_done_marker_from_line(line, line_str, data)
|
| 292 |
+
yield cleaned_line
|
| 293 |
+
|
| 294 |
+
except (json.JSONDecodeError, ValueError):
|
| 295 |
+
# 无法解析的行,直接传递
|
| 296 |
+
yield line
|
| 297 |
+
continue
|
| 298 |
+
else:
|
| 299 |
+
# 非 data: 开头的行,直接传递
|
| 300 |
+
yield line
|
| 301 |
+
|
| 302 |
+
# 更新收集的内容 - 使用 StringIO 高效处理
|
| 303 |
+
chunk_text = chunk_buffer.getvalue()
|
| 304 |
+
if chunk_text:
|
| 305 |
+
self._append_content(chunk_text)
|
| 306 |
+
chunk_buffer.close()
|
| 307 |
+
|
| 308 |
+
log.debug(f"Anti-truncation: After processing stream, found_done_marker={found_done_marker}")
|
| 309 |
+
|
| 310 |
+
# 如果找到了done标记,结束
|
| 311 |
+
if found_done_marker:
|
| 312 |
+
# 立即清理内容释放内存
|
| 313 |
+
self._clear_content()
|
| 314 |
+
yield b"data: [DONE]\n\n"
|
| 315 |
+
return
|
| 316 |
+
|
| 317 |
+
# 只有在单个chunk中没有找到done标记时,才检查累积内容(防止done标记跨chunk出现)
|
| 318 |
+
if not found_done_marker:
|
| 319 |
+
accumulated_text = self._get_collected_text()
|
| 320 |
+
if self._check_done_marker_in_text(accumulated_text):
|
| 321 |
+
log.info("Anti-truncation: Found [done] marker in accumulated content")
|
| 322 |
+
# 立即清理内容释放内存
|
| 323 |
+
self._clear_content()
|
| 324 |
+
yield b"data: [DONE]\n\n"
|
| 325 |
+
return
|
| 326 |
+
|
| 327 |
+
# 如果没找到done标记且不是最后一次尝试,准备续传
|
| 328 |
+
if self.current_attempt < self.max_attempts:
|
| 329 |
+
accumulated_text = self._get_collected_text()
|
| 330 |
+
total_length = len(accumulated_text)
|
| 331 |
+
log.info(
|
| 332 |
+
f"Anti-truncation: No [done] marker found in output (length: {total_length}), preparing continuation (attempt {self.current_attempt + 1})"
|
| 333 |
+
)
|
| 334 |
+
if total_length > 100:
|
| 335 |
+
log.debug(
|
| 336 |
+
f"Anti-truncation: Current collected content ends with: ...{accumulated_text[-100:]}"
|
| 337 |
+
)
|
| 338 |
+
# 在下一次循环中会继续
|
| 339 |
+
continue
|
| 340 |
+
else:
|
| 341 |
+
# 最后一次尝试,直接结束
|
| 342 |
+
log.warning("Anti-truncation: Max attempts reached, ending stream")
|
| 343 |
+
# 立即清理内容释放内存
|
| 344 |
+
self._clear_content()
|
| 345 |
+
yield b"data: [DONE]\n\n"
|
| 346 |
+
return
|
| 347 |
+
|
| 348 |
+
except Exception as e:
|
| 349 |
+
log.error(f"Anti-truncation error in attempt {self.current_attempt}: {str(e)}")
|
| 350 |
+
if self.current_attempt >= self.max_attempts:
|
| 351 |
+
# 发送错误chunk
|
| 352 |
+
error_chunk = {
|
| 353 |
+
"error": {
|
| 354 |
+
"message": f"Anti-truncation failed: {str(e)}",
|
| 355 |
+
"type": "api_error",
|
| 356 |
+
"code": 500,
|
| 357 |
+
}
|
| 358 |
+
}
|
| 359 |
+
yield f"data: {json.dumps(error_chunk)}\n\n".encode()
|
| 360 |
+
yield b"data: [DONE]\n\n"
|
| 361 |
+
return
|
| 362 |
+
# 否则继续下一次尝试
|
| 363 |
+
|
| 364 |
+
# 如果所有尝试都失败了
|
| 365 |
+
log.error("Anti-truncation: All attempts failed")
|
| 366 |
+
# 清理内存
|
| 367 |
+
self._clear_content()
|
| 368 |
+
yield b"data: [DONE]\n\n"
|
| 369 |
+
|
| 370 |
+
def _build_current_payload(self) -> Dict[str, Any]:
|
| 371 |
+
"""构建当前请求的payload"""
|
| 372 |
+
if self.current_attempt == 1:
|
| 373 |
+
# 第一次请求,使用原始payload(已经包含反截断指令)
|
| 374 |
+
return self.base_payload
|
| 375 |
+
|
| 376 |
+
# 后续请求,添加续传指令
|
| 377 |
+
continuation_payload = self.base_payload.copy()
|
| 378 |
+
request_data = continuation_payload.get("request", {})
|
| 379 |
+
|
| 380 |
+
# 获取原始对话内容
|
| 381 |
+
contents = request_data.get("contents", [])
|
| 382 |
+
new_contents = contents.copy()
|
| 383 |
+
|
| 384 |
+
# 如果有收集到的内容,添加到对话中
|
| 385 |
+
accumulated_text = self._get_collected_text()
|
| 386 |
+
if accumulated_text:
|
| 387 |
+
new_contents.append({"role": "model", "parts": [{"text": accumulated_text}]})
|
| 388 |
+
|
| 389 |
+
# 构建具体的续写指令,包含前面的内容摘要
|
| 390 |
+
content_summary = ""
|
| 391 |
+
if accumulated_text:
|
| 392 |
+
if len(accumulated_text) > 200:
|
| 393 |
+
content_summary = f'\n\n前面你已经输出了约 {len(accumulated_text)} 个字符的内容,结尾是:\n"...{accumulated_text[-100:]}"'
|
| 394 |
+
else:
|
| 395 |
+
content_summary = f'\n\n前面你已经输出的内容是:\n"{accumulated_text}"'
|
| 396 |
+
|
| 397 |
+
detailed_continuation_prompt = f"""{CONTINUATION_PROMPT}{content_summary}"""
|
| 398 |
+
|
| 399 |
+
# 添加继续指令
|
| 400 |
+
continuation_message = {"role": "user", "parts": [{"text": detailed_continuation_prompt}]}
|
| 401 |
+
new_contents.append(continuation_message)
|
| 402 |
+
|
| 403 |
+
request_data["contents"] = new_contents
|
| 404 |
+
continuation_payload["request"] = request_data
|
| 405 |
+
|
| 406 |
+
return continuation_payload
|
| 407 |
+
|
| 408 |
+
def _extract_content_from_chunk(self, data: Dict[str, Any]) -> str:
|
| 409 |
+
"""从chunk数据中提取文本内容"""
|
| 410 |
+
content = ""
|
| 411 |
+
|
| 412 |
+
# 先尝试解包 response 字段(Gemini API 格式)
|
| 413 |
+
if "response" in data:
|
| 414 |
+
data = data["response"]
|
| 415 |
+
|
| 416 |
+
# 处理 Gemini 格式
|
| 417 |
+
if "candidates" in data:
|
| 418 |
+
for candidate in data["candidates"]:
|
| 419 |
+
if "content" in candidate:
|
| 420 |
+
parts = candidate["content"].get("parts", [])
|
| 421 |
+
for part in parts:
|
| 422 |
+
if "text" in part:
|
| 423 |
+
content += part["text"]
|
| 424 |
+
|
| 425 |
+
# 处理 OpenAI 流式格式(choices/delta)
|
| 426 |
+
elif "choices" in data:
|
| 427 |
+
for choice in data["choices"]:
|
| 428 |
+
if "delta" in choice and "content" in choice["delta"]:
|
| 429 |
+
delta_content = choice["delta"]["content"]
|
| 430 |
+
if delta_content:
|
| 431 |
+
content += delta_content
|
| 432 |
+
|
| 433 |
+
return content
|
| 434 |
+
|
| 435 |
+
async def _handle_non_streaming_response(self, response) -> bytes:
|
| 436 |
+
"""处理非流式响应 - 使用循环代替递归避免栈溢出"""
|
| 437 |
+
# 使用循环代替递归
|
| 438 |
+
while True:
|
| 439 |
+
try:
|
| 440 |
+
# 特殊处理:如果返回的是StreamingResponse,需要读取其body_iterator
|
| 441 |
+
if isinstance(response, StreamingResponse):
|
| 442 |
+
log.error("Anti-truncation: Received StreamingResponse in non-streaming handler - this should not happen")
|
| 443 |
+
# 尝试读取流式响应的内容
|
| 444 |
+
chunks = []
|
| 445 |
+
async for chunk in response.body_iterator:
|
| 446 |
+
chunks.append(chunk)
|
| 447 |
+
content = b"".join(chunks).decode() if chunks else ""
|
| 448 |
+
# 提取响应内容
|
| 449 |
+
elif hasattr(response, "body"):
|
| 450 |
+
content = (
|
| 451 |
+
response.body.decode() if isinstance(response.body, bytes) else response.body
|
| 452 |
+
)
|
| 453 |
+
elif hasattr(response, "content"):
|
| 454 |
+
content = (
|
| 455 |
+
response.content.decode()
|
| 456 |
+
if isinstance(response.content, bytes)
|
| 457 |
+
else response.content
|
| 458 |
+
)
|
| 459 |
+
else:
|
| 460 |
+
log.error(f"Anti-truncation: Unknown response type: {type(response)}")
|
| 461 |
+
content = str(response)
|
| 462 |
+
|
| 463 |
+
# 验证内容不为空
|
| 464 |
+
if not content or not content.strip():
|
| 465 |
+
log.error("Anti-truncation: Received empty response content")
|
| 466 |
+
return json.dumps(
|
| 467 |
+
{
|
| 468 |
+
"error": {
|
| 469 |
+
"message": "Empty response from server",
|
| 470 |
+
"type": "api_error",
|
| 471 |
+
"code": 500,
|
| 472 |
+
}
|
| 473 |
+
}
|
| 474 |
+
).encode()
|
| 475 |
+
|
| 476 |
+
# 尝试解析 JSON
|
| 477 |
+
try:
|
| 478 |
+
response_data = json.loads(content)
|
| 479 |
+
except json.JSONDecodeError as json_err:
|
| 480 |
+
log.error(f"Anti-truncation: Failed to parse JSON response: {json_err}, content: {content[:200]}")
|
| 481 |
+
# 如果不是 JSON,直接返回原始内容
|
| 482 |
+
return content.encode() if isinstance(content, str) else content
|
| 483 |
+
|
| 484 |
+
# 检查是否包含done标记
|
| 485 |
+
text_content = self._extract_content_from_response(response_data)
|
| 486 |
+
has_done_marker = self._check_done_marker_in_text(text_content)
|
| 487 |
+
|
| 488 |
+
if has_done_marker or self.current_attempt >= self.max_attempts:
|
| 489 |
+
# 找到done标记或达到最大尝试次数,返回结果
|
| 490 |
+
return content.encode() if isinstance(content, str) else content
|
| 491 |
+
|
| 492 |
+
# 需要继续,收集内容并构建下一个请求
|
| 493 |
+
if text_content:
|
| 494 |
+
self._append_content(text_content)
|
| 495 |
+
|
| 496 |
+
log.info("Anti-truncation: Non-streaming response needs continuation")
|
| 497 |
+
|
| 498 |
+
# 增加尝试次数
|
| 499 |
+
self.current_attempt += 1
|
| 500 |
+
|
| 501 |
+
# 构建续传payload并发送下一个请求
|
| 502 |
+
next_payload = self._build_current_payload()
|
| 503 |
+
response = await self.original_request_func(next_payload)
|
| 504 |
+
|
| 505 |
+
# 继续循环处理下一个响应
|
| 506 |
+
|
| 507 |
+
except Exception as e:
|
| 508 |
+
log.error(f"Anti-truncation non-streaming error: {str(e)}")
|
| 509 |
+
return json.dumps(
|
| 510 |
+
{
|
| 511 |
+
"error": {
|
| 512 |
+
"message": f"Anti-truncation failed: {str(e)}",
|
| 513 |
+
"type": "api_error",
|
| 514 |
+
"code": 500,
|
| 515 |
+
}
|
| 516 |
+
}
|
| 517 |
+
).encode()
|
| 518 |
+
|
| 519 |
+
def _check_done_marker_in_text(self, text: str) -> bool:
|
| 520 |
+
"""检测文本中是否包含DONE_MARKER(只检测指定标记)"""
|
| 521 |
+
if not text:
|
| 522 |
+
return False
|
| 523 |
+
|
| 524 |
+
# 只要文本中出现DONE_MARKER即可
|
| 525 |
+
return DONE_MARKER in text
|
| 526 |
+
|
| 527 |
+
def _check_done_marker_in_chunk_content(self, content: str) -> bool:
|
| 528 |
+
"""检查单个chunk内容中是否包含done标记"""
|
| 529 |
+
return self._check_done_marker_in_text(content)
|
| 530 |
+
|
| 531 |
+
def _extract_content_from_response(self, data: Dict[str, Any]) -> str:
|
| 532 |
+
"""从响应数据中提取文本内容"""
|
| 533 |
+
content = ""
|
| 534 |
+
|
| 535 |
+
# 先尝试解包 response 字段(Gemini API 格式)
|
| 536 |
+
if "response" in data:
|
| 537 |
+
data = data["response"]
|
| 538 |
+
|
| 539 |
+
# 处理Gemini格式
|
| 540 |
+
if "candidates" in data:
|
| 541 |
+
for candidate in data["candidates"]:
|
| 542 |
+
if "content" in candidate:
|
| 543 |
+
parts = candidate["content"].get("parts", [])
|
| 544 |
+
for part in parts:
|
| 545 |
+
if "text" in part:
|
| 546 |
+
content += part["text"]
|
| 547 |
+
|
| 548 |
+
# 处理OpenAI格式
|
| 549 |
+
elif "choices" in data:
|
| 550 |
+
for choice in data["choices"]:
|
| 551 |
+
if "message" in choice and "content" in choice["message"]:
|
| 552 |
+
content += choice["message"]["content"]
|
| 553 |
+
|
| 554 |
+
return content
|
| 555 |
+
|
| 556 |
+
def _remove_done_marker_from_line(self, line: bytes, line_str: str, data: Dict[str, Any]) -> bytes:
|
| 557 |
+
"""从行中移除[done]标记"""
|
| 558 |
+
try:
|
| 559 |
+
# 首先检查是否真的包含[done]标记
|
| 560 |
+
if "[done]" not in line_str.lower():
|
| 561 |
+
return line # 没有[done]标记,直接返回原始行
|
| 562 |
+
|
| 563 |
+
log.info(f"Anti-truncation: Attempting to remove [done] marker from line")
|
| 564 |
+
log.debug(f"Anti-truncation: Original line (first 200 chars): {line_str[:200]}")
|
| 565 |
+
|
| 566 |
+
# 编译正则表达式,匹配[done]标记(忽略大小写,包括可能的空白字符)
|
| 567 |
+
done_pattern = re.compile(r"\s*\[done\]\s*", re.IGNORECASE)
|
| 568 |
+
|
| 569 |
+
# 检查是否有 response 包裹层
|
| 570 |
+
has_response_wrapper = "response" in data
|
| 571 |
+
log.debug(f"Anti-truncation: has_response_wrapper={has_response_wrapper}, data keys={list(data.keys())}")
|
| 572 |
+
if has_response_wrapper:
|
| 573 |
+
# 需要保留外层的 response 字段
|
| 574 |
+
inner_data = data["response"]
|
| 575 |
+
else:
|
| 576 |
+
inner_data = data
|
| 577 |
+
|
| 578 |
+
log.debug(f"Anti-truncation: inner_data keys={list(inner_data.keys())}")
|
| 579 |
+
|
| 580 |
+
log.debug(f"Anti-truncation: inner_data keys={list(inner_data.keys())}")
|
| 581 |
+
|
| 582 |
+
# 处理Gemini格式
|
| 583 |
+
if "candidates" in inner_data:
|
| 584 |
+
log.info(f"Anti-truncation: Processing Gemini format to remove [done] marker")
|
| 585 |
+
modified_inner = inner_data.copy()
|
| 586 |
+
modified_inner["candidates"] = []
|
| 587 |
+
|
| 588 |
+
for i, candidate in enumerate(inner_data["candidates"]):
|
| 589 |
+
modified_candidate = candidate.copy()
|
| 590 |
+
# 只在最后一个candidate中清理[done]标记
|
| 591 |
+
is_last_candidate = i == len(inner_data["candidates"]) - 1
|
| 592 |
+
|
| 593 |
+
if "content" in candidate:
|
| 594 |
+
modified_content = candidate["content"].copy()
|
| 595 |
+
if "parts" in modified_content:
|
| 596 |
+
modified_parts = []
|
| 597 |
+
for part in modified_content["parts"]:
|
| 598 |
+
if "text" in part and isinstance(part["text"], str):
|
| 599 |
+
modified_part = part.copy()
|
| 600 |
+
original_text = part["text"]
|
| 601 |
+
# 只在最后一个candidate中清理[done]标记
|
| 602 |
+
if is_last_candidate:
|
| 603 |
+
modified_part["text"] = done_pattern.sub("", part["text"])
|
| 604 |
+
if "[done]" in original_text.lower():
|
| 605 |
+
log.debug(f"Anti-truncation: Removed [done] from text: '{original_text[:100]}' -> '{modified_part['text'][:100]}'")
|
| 606 |
+
modified_parts.append(modified_part)
|
| 607 |
+
else:
|
| 608 |
+
modified_parts.append(part)
|
| 609 |
+
modified_content["parts"] = modified_parts
|
| 610 |
+
modified_candidate["content"] = modified_content
|
| 611 |
+
modified_inner["candidates"].append(modified_candidate)
|
| 612 |
+
|
| 613 |
+
# 如果有 response 包裹层,需要重新包装
|
| 614 |
+
if has_response_wrapper:
|
| 615 |
+
modified_data = data.copy()
|
| 616 |
+
modified_data["response"] = modified_inner
|
| 617 |
+
else:
|
| 618 |
+
modified_data = modified_inner
|
| 619 |
+
|
| 620 |
+
# 重新编码为行格式 - SSE格式需要两个换行符
|
| 621 |
+
json_str = json.dumps(modified_data, separators=(",", ":"), ensure_ascii=False)
|
| 622 |
+
result = f"data: {json_str}\n\n".encode("utf-8")
|
| 623 |
+
log.debug(f"Anti-truncation: Modified line (first 200 chars): {result.decode('utf-8', errors='ignore')[:200]}")
|
| 624 |
+
return result
|
| 625 |
+
|
| 626 |
+
# 处理OpenAI格式
|
| 627 |
+
elif "choices" in inner_data:
|
| 628 |
+
modified_inner = inner_data.copy()
|
| 629 |
+
modified_inner["choices"] = []
|
| 630 |
+
|
| 631 |
+
for choice in inner_data["choices"]:
|
| 632 |
+
modified_choice = choice.copy()
|
| 633 |
+
if "delta" in choice and "content" in choice["delta"]:
|
| 634 |
+
modified_delta = choice["delta"].copy()
|
| 635 |
+
modified_delta["content"] = done_pattern.sub("", choice["delta"]["content"])
|
| 636 |
+
modified_choice["delta"] = modified_delta
|
| 637 |
+
elif "message" in choice and "content" in choice["message"]:
|
| 638 |
+
modified_message = choice["message"].copy()
|
| 639 |
+
modified_message["content"] = done_pattern.sub("", choice["message"]["content"])
|
| 640 |
+
modified_choice["message"] = modified_message
|
| 641 |
+
modified_inner["choices"].append(modified_choice)
|
| 642 |
+
|
| 643 |
+
# 如果有 response 包裹层,需要重新包装
|
| 644 |
+
if has_response_wrapper:
|
| 645 |
+
modified_data = data.copy()
|
| 646 |
+
modified_data["response"] = modified_inner
|
| 647 |
+
else:
|
| 648 |
+
modified_data = modified_inner
|
| 649 |
+
|
| 650 |
+
# 重新编码为行格式 - SSE格式需要两个换行符
|
| 651 |
+
json_str = json.dumps(modified_data, separators=(",", ":"), ensure_ascii=False)
|
| 652 |
+
return f"data: {json_str}\n\n".encode("utf-8")
|
| 653 |
+
|
| 654 |
+
# 如果没有找到支持的格式,返回原始行
|
| 655 |
+
return line
|
| 656 |
+
|
| 657 |
+
except Exception as e:
|
| 658 |
+
log.warning(f"Failed to remove [done] marker from line: {str(e)}")
|
| 659 |
+
return line
|
| 660 |
+
|
| 661 |
+
|
| 662 |
+
async def apply_anti_truncation_to_stream(
|
| 663 |
+
request_func, payload: Dict[str, Any], max_attempts: int = 3
|
| 664 |
+
) -> StreamingResponse:
|
| 665 |
+
"""
|
| 666 |
+
对流式请求应用反截断处理
|
| 667 |
+
|
| 668 |
+
Args:
|
| 669 |
+
request_func: 原始请求函数
|
| 670 |
+
payload: 请求payload
|
| 671 |
+
max_attempts: 最大续传尝试次数
|
| 672 |
+
|
| 673 |
+
Returns:
|
| 674 |
+
处理后的StreamingResponse
|
| 675 |
+
"""
|
| 676 |
+
|
| 677 |
+
# 首先对payload应用反截断指令
|
| 678 |
+
anti_truncation_payload = apply_anti_truncation(payload)
|
| 679 |
+
|
| 680 |
+
# 创建反截断处理器
|
| 681 |
+
processor = AntiTruncationStreamProcessor(
|
| 682 |
+
lambda p: request_func(p), anti_truncation_payload, max_attempts
|
| 683 |
+
)
|
| 684 |
+
|
| 685 |
+
# 返回包装后的流式响应
|
| 686 |
+
return StreamingResponse(processor.process_stream(), media_type="text/event-stream")
|
| 687 |
+
|
| 688 |
+
|
| 689 |
+
def is_anti_truncation_enabled(request_data: Dict[str, Any]) -> bool:
|
| 690 |
+
"""
|
| 691 |
+
检查请求是否启用了反截断功能
|
| 692 |
+
|
| 693 |
+
Args:
|
| 694 |
+
request_data: 请求数据
|
| 695 |
+
|
| 696 |
+
Returns:
|
| 697 |
+
是否启用反截断
|
| 698 |
+
"""
|
| 699 |
+
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,418 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Gemini Format Utilities - 统一的 Gemini 格式处理和转换工具
|
| 3 |
+
提供对 Gemini API 请求体和响应的标准化处理
|
| 4 |
+
────────────────────────────────────────────────────────────────
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from typing import Any, Dict, List, Optional
|
| 8 |
+
|
| 9 |
+
from log import log
|
| 10 |
+
|
| 11 |
+
# ==================== Gemini API 配置 ====================
|
| 12 |
+
|
| 13 |
+
# Gemini API 不支持的 JSON Schema 字段集合
|
| 14 |
+
# 参考: github.com/googleapis/python-genai/issues/699, #388, #460, #1122, #264, #4551
|
| 15 |
+
UNSUPPORTED_SCHEMA_KEYS = {
|
| 16 |
+
'$schema', '$id', '$ref', '$defs', 'definitions',
|
| 17 |
+
'example', 'examples', 'readOnly', 'writeOnly', 'default',
|
| 18 |
+
'exclusiveMaximum', 'exclusiveMinimum',
|
| 19 |
+
'oneOf', 'anyOf', 'allOf', 'const',
|
| 20 |
+
'additionalItems', 'contains', 'patternProperties', 'dependencies',
|
| 21 |
+
'propertyNames', 'if', 'then', 'else',
|
| 22 |
+
'contentEncoding', 'contentMediaType',
|
| 23 |
+
'additionalProperties', 'minLength', 'maxLength',
|
| 24 |
+
'minItems', 'maxItems', 'uniqueItems'
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def clean_tools_for_gemini(tools: Optional[List[Dict[str, Any]]]) -> Optional[List[Dict[str, Any]]]:
|
| 30 |
+
"""
|
| 31 |
+
清理工具定义,移除 Gemini API 不支持的 JSON Schema 字段
|
| 32 |
+
|
| 33 |
+
Gemini API 只支持有限的 OpenAPI 3.0 Schema 属性:
|
| 34 |
+
- 支持: type, description, enum, items, properties, required, nullable, format
|
| 35 |
+
- 不支持: $schema, $id, $ref, $defs, title, examples, default, readOnly,
|
| 36 |
+
exclusiveMaximum, exclusiveMinimum, oneOf, anyOf, allOf, const 等
|
| 37 |
+
|
| 38 |
+
Args:
|
| 39 |
+
tools: 工具定义列表
|
| 40 |
+
|
| 41 |
+
Returns:
|
| 42 |
+
清理后的工具定义列表
|
| 43 |
+
"""
|
| 44 |
+
if not tools:
|
| 45 |
+
return tools
|
| 46 |
+
|
| 47 |
+
def clean_schema(obj: Any) -> Any:
|
| 48 |
+
"""递归清理 schema 对象"""
|
| 49 |
+
if isinstance(obj, dict):
|
| 50 |
+
cleaned = {}
|
| 51 |
+
for key, value in obj.items():
|
| 52 |
+
if key in UNSUPPORTED_SCHEMA_KEYS:
|
| 53 |
+
continue
|
| 54 |
+
cleaned[key] = clean_schema(value)
|
| 55 |
+
# 确保有 type 字段(如果有 properties 但没有 type)
|
| 56 |
+
if "properties" in cleaned and "type" not in cleaned:
|
| 57 |
+
cleaned["type"] = "object"
|
| 58 |
+
return cleaned
|
| 59 |
+
elif isinstance(obj, list):
|
| 60 |
+
return [clean_schema(item) for item in obj]
|
| 61 |
+
else:
|
| 62 |
+
return obj
|
| 63 |
+
|
| 64 |
+
# 清理每个工具的参数
|
| 65 |
+
cleaned_tools = []
|
| 66 |
+
for tool in tools:
|
| 67 |
+
if not isinstance(tool, dict):
|
| 68 |
+
cleaned_tools.append(tool)
|
| 69 |
+
continue
|
| 70 |
+
|
| 71 |
+
cleaned_tool = tool.copy()
|
| 72 |
+
|
| 73 |
+
# 清理 functionDeclarations
|
| 74 |
+
if "functionDeclarations" in cleaned_tool:
|
| 75 |
+
cleaned_declarations = []
|
| 76 |
+
for func_decl in cleaned_tool["functionDeclarations"]:
|
| 77 |
+
if not isinstance(func_decl, dict):
|
| 78 |
+
cleaned_declarations.append(func_decl)
|
| 79 |
+
continue
|
| 80 |
+
|
| 81 |
+
cleaned_decl = func_decl.copy()
|
| 82 |
+
if "parameters" in cleaned_decl:
|
| 83 |
+
cleaned_decl["parameters"] = clean_schema(cleaned_decl["parameters"])
|
| 84 |
+
cleaned_declarations.append(cleaned_decl)
|
| 85 |
+
|
| 86 |
+
cleaned_tool["functionDeclarations"] = cleaned_declarations
|
| 87 |
+
|
| 88 |
+
cleaned_tools.append(cleaned_tool)
|
| 89 |
+
|
| 90 |
+
return cleaned_tools
|
| 91 |
+
|
| 92 |
+
def prepare_image_generation_request(
|
| 93 |
+
request_body: Dict[str, Any],
|
| 94 |
+
model: str
|
| 95 |
+
) -> Dict[str, Any]:
|
| 96 |
+
"""
|
| 97 |
+
图像生成模型请求体后处理
|
| 98 |
+
|
| 99 |
+
Args:
|
| 100 |
+
request_body: 原始请求体
|
| 101 |
+
model: 模型名称
|
| 102 |
+
|
| 103 |
+
Returns:
|
| 104 |
+
处理后的请求体
|
| 105 |
+
"""
|
| 106 |
+
request_body = request_body.copy()
|
| 107 |
+
model_lower = model.lower()
|
| 108 |
+
|
| 109 |
+
# 解析分辨率
|
| 110 |
+
image_size = "4K" if "-4k" in model_lower else "2K" if "-2k" in model_lower else None
|
| 111 |
+
|
| 112 |
+
# 解析比例
|
| 113 |
+
aspect_ratio = None
|
| 114 |
+
for suffix, ratio in [
|
| 115 |
+
("-21x9", "21:9"), ("-16x9", "16:9"), ("-9x16", "9:16"),
|
| 116 |
+
("-4x3", "4:3"), ("-3x4", "3:4"), ("-1x1", "1:1")
|
| 117 |
+
]:
|
| 118 |
+
if suffix in model_lower:
|
| 119 |
+
aspect_ratio = ratio
|
| 120 |
+
break
|
| 121 |
+
|
| 122 |
+
# 构建 imageConfig
|
| 123 |
+
image_config = {}
|
| 124 |
+
if aspect_ratio:
|
| 125 |
+
image_config["aspectRatio"] = aspect_ratio
|
| 126 |
+
if image_size:
|
| 127 |
+
image_config["imageSize"] = image_size
|
| 128 |
+
|
| 129 |
+
request_body["model"] = "gemini-3-pro-image" # 统一使用基础模型名
|
| 130 |
+
request_body["generationConfig"] = {
|
| 131 |
+
"candidateCount": 1,
|
| 132 |
+
"imageConfig": image_config
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
# 移除不需要的字段
|
| 136 |
+
for key in ("systemInstruction", "tools", "toolConfig"):
|
| 137 |
+
request_body.pop(key, None)
|
| 138 |
+
|
| 139 |
+
return request_body
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
# ==================== 模型特性辅助函数 ====================
|
| 143 |
+
|
| 144 |
+
def get_base_model_name(model_name: str) -> str:
|
| 145 |
+
"""移除模型名称中的后缀,返回基础模型名"""
|
| 146 |
+
# 按照从长到短的顺序排列,避免 -think 先于 -maxthinking 被匹配
|
| 147 |
+
suffixes = ["-maxthinking", "-nothinking", "-search", "-think"]
|
| 148 |
+
result = model_name
|
| 149 |
+
changed = True
|
| 150 |
+
# 持续循环直到没有任何后缀可以移除
|
| 151 |
+
while changed:
|
| 152 |
+
changed = False
|
| 153 |
+
for suffix in suffixes:
|
| 154 |
+
if result.endswith(suffix):
|
| 155 |
+
result = result[:-len(suffix)]
|
| 156 |
+
changed = True
|
| 157 |
+
# 不使用 break,继续检查是否还有其他后缀
|
| 158 |
+
return result
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
def get_thinking_settings(model_name: str) -> tuple[Optional[int], bool]:
|
| 162 |
+
"""
|
| 163 |
+
根据模型名称获取思考配置
|
| 164 |
+
|
| 165 |
+
Returns:
|
| 166 |
+
(thinking_budget, include_thoughts): 思考预算和是否包含思考内容
|
| 167 |
+
"""
|
| 168 |
+
base_model = get_base_model_name(model_name)
|
| 169 |
+
|
| 170 |
+
if "-nothinking" in model_name:
|
| 171 |
+
# nothinking 模式: 限制思考,pro模型仍包含thoughts
|
| 172 |
+
return 128, "pro" in base_model
|
| 173 |
+
elif "-maxthinking" in model_name:
|
| 174 |
+
# maxthinking 模式: 最大思考预算
|
| 175 |
+
budget = 24576 if "flash" in base_model else 32768
|
| 176 |
+
return budget, True
|
| 177 |
+
else:
|
| 178 |
+
# 默认模式: 不设置thinking budget
|
| 179 |
+
return None, True
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
def is_search_model(model_name: str) -> bool:
|
| 183 |
+
"""检查是否为搜索模型"""
|
| 184 |
+
return "-search" in model_name
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
# ==================== 统一的 Gemini 请求后处理 ====================
|
| 188 |
+
|
| 189 |
+
def is_thinking_model(model_name: str) -> bool:
|
| 190 |
+
"""检查是否为思考模型 (包含 -thinking 或 pro)"""
|
| 191 |
+
return "-thinking" in model_name or "pro" in model_name.lower()
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
def check_last_assistant_has_thinking(contents: List[Dict[str, Any]]) -> bool:
|
| 195 |
+
"""
|
| 196 |
+
检查最后一个 assistant 消息是否以 thinking 块开始
|
| 197 |
+
|
| 198 |
+
根据 Claude API 要求:当启用 thinking 时,最后一个 assistant 消息必须以 thinking 块开始
|
| 199 |
+
|
| 200 |
+
Args:
|
| 201 |
+
contents: Gemini 格式的 contents 数组
|
| 202 |
+
|
| 203 |
+
Returns:
|
| 204 |
+
如果最后一个 assistant 消息以 thinking 块开始则返回 True,否则返回 False
|
| 205 |
+
"""
|
| 206 |
+
if not contents:
|
| 207 |
+
return True # 没有 contents,允许启用 thinking
|
| 208 |
+
|
| 209 |
+
# 从后往前找最后一个 assistant (model) 消息
|
| 210 |
+
last_assistant_content = None
|
| 211 |
+
for content in reversed(contents):
|
| 212 |
+
if isinstance(content, dict) and content.get("role") == "model":
|
| 213 |
+
last_assistant_content = content
|
| 214 |
+
break
|
| 215 |
+
|
| 216 |
+
if not last_assistant_content:
|
| 217 |
+
return True # 没有 assistant 消息,允许启用 thinking
|
| 218 |
+
|
| 219 |
+
# 检查第一个 part 是否是 thinking 块
|
| 220 |
+
parts = last_assistant_content.get("parts", [])
|
| 221 |
+
if not parts:
|
| 222 |
+
return False # 有 assistant 消息但没有 parts,不允许 thinking
|
| 223 |
+
|
| 224 |
+
first_part = parts[0]
|
| 225 |
+
if not isinstance(first_part, dict):
|
| 226 |
+
return False
|
| 227 |
+
|
| 228 |
+
# 检查是否是 thinking 块(有 thought 字段且为 True)
|
| 229 |
+
return first_part.get("thought") is True
|
| 230 |
+
|
| 231 |
+
|
| 232 |
+
async def normalize_gemini_request(
|
| 233 |
+
request: Dict[str, Any],
|
| 234 |
+
mode: str = "geminicli"
|
| 235 |
+
) -> Dict[str, Any]:
|
| 236 |
+
"""
|
| 237 |
+
规范化 Gemini 请求
|
| 238 |
+
|
| 239 |
+
处理逻辑:
|
| 240 |
+
1. 模型特性处理 (thinking config, search tools)
|
| 241 |
+
2. 字段名转换 (system_instructions -> systemInstruction)
|
| 242 |
+
3. 参数范围限制 (maxOutputTokens, topK)
|
| 243 |
+
4. 工具清理
|
| 244 |
+
|
| 245 |
+
Args:
|
| 246 |
+
request: 原始请求字典
|
| 247 |
+
mode: 模式 ("geminicli" 或 "antigravity")
|
| 248 |
+
|
| 249 |
+
Returns:
|
| 250 |
+
规范化后的请求
|
| 251 |
+
"""
|
| 252 |
+
# 导入配置函数
|
| 253 |
+
from config import get_return_thoughts_to_frontend
|
| 254 |
+
|
| 255 |
+
result = request.copy()
|
| 256 |
+
model = result.get("model", "")
|
| 257 |
+
generation_config = (result.get("generationConfig") or {}).copy() # 创建副本避免修改原对象
|
| 258 |
+
tools = result.get("tools")
|
| 259 |
+
system_instruction = result.get("systemInstruction") or result.get("system_instructions")
|
| 260 |
+
|
| 261 |
+
# 记录原始请求
|
| 262 |
+
log.debug(f"[GEMINI_FIX] 原始请求 - 模型: {model}, mode: {mode}, generationConfig: {generation_config}")
|
| 263 |
+
|
| 264 |
+
# 获取配置值
|
| 265 |
+
return_thoughts = await get_return_thoughts_to_frontend()
|
| 266 |
+
|
| 267 |
+
# ========== 模式特定处理 ==========
|
| 268 |
+
if mode == "geminicli":
|
| 269 |
+
# 1. 思考设置
|
| 270 |
+
thinking_budget, include_thoughts = get_thinking_settings(model)
|
| 271 |
+
if thinking_budget is not None and "thinkingConfig" not in generation_config:
|
| 272 |
+
# 如果配置为不返回thoughts,则强制设置为False;否则使用模型默认设置
|
| 273 |
+
final_include_thoughts = include_thoughts if return_thoughts else False
|
| 274 |
+
generation_config["thinkingConfig"] = {
|
| 275 |
+
"thinkingBudget": thinking_budget,
|
| 276 |
+
"includeThoughts": final_include_thoughts
|
| 277 |
+
}
|
| 278 |
+
|
| 279 |
+
# 2. 工具清理和处理
|
| 280 |
+
if tools:
|
| 281 |
+
result["tools"] = clean_tools_for_gemini(tools)
|
| 282 |
+
|
| 283 |
+
# 3. 搜索模型添加 Google Search
|
| 284 |
+
if is_search_model(model):
|
| 285 |
+
result_tools = result.get("tools") or []
|
| 286 |
+
result["tools"] = result_tools
|
| 287 |
+
if not any(tool.get("googleSearch") for tool in result_tools if isinstance(tool, dict)):
|
| 288 |
+
result_tools.append({"googleSearch": {}})
|
| 289 |
+
|
| 290 |
+
# 4. 模型名称处理
|
| 291 |
+
result["model"] = get_base_model_name(model)
|
| 292 |
+
|
| 293 |
+
elif mode == "antigravity":
|
| 294 |
+
# 1. 处理 system_instruction
|
| 295 |
+
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]"
|
| 296 |
+
|
| 297 |
+
# 提取原有的 parts(如果存在)
|
| 298 |
+
existing_parts = []
|
| 299 |
+
if system_instruction:
|
| 300 |
+
if isinstance(system_instruction, dict):
|
| 301 |
+
existing_parts = system_instruction.get("parts", [])
|
| 302 |
+
|
| 303 |
+
# custom_prompt 始终放在第一位,原有内容整体后移
|
| 304 |
+
result["systemInstruction"] = {
|
| 305 |
+
"parts": [{"text": custom_prompt}] + existing_parts
|
| 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):
|
| 315 |
+
# 检查最后一个 assistant 消息是否以 thinking 块开始
|
| 316 |
+
contents = result.get("contents", [])
|
| 317 |
+
can_enable_thinking = check_last_assistant_has_thinking(contents)
|
| 318 |
+
|
| 319 |
+
if can_enable_thinking:
|
| 320 |
+
if "thinkingConfig" not in generation_config:
|
| 321 |
+
generation_config["thinkingConfig"] = {}
|
| 322 |
+
|
| 323 |
+
thinking_config = generation_config["thinkingConfig"]
|
| 324 |
+
# 优先使用传入的思考预算,否则使用默认值
|
| 325 |
+
if "thinkingBudget" not in thinking_config:
|
| 326 |
+
thinking_config["thinkingBudget"] = 1024
|
| 327 |
+
if "includeThoughts" not in thinking_config:
|
| 328 |
+
thinking_config["includeThoughts"] = return_thoughts
|
| 329 |
+
else:
|
| 330 |
+
# 最后一个 assistant 消息不是以 thinking 块开始,禁用 thinking
|
| 331 |
+
log.warning(f"[ANTIGRAVITY] 最后一个 assistant 消息不以 thinking 块开始,禁用 thinkingConfig")
|
| 332 |
+
# 移除可能存在的 thinkingConfig
|
| 333 |
+
generation_config.pop("thinkingConfig", None)
|
| 334 |
+
|
| 335 |
+
# 移除 -thinking 后缀
|
| 336 |
+
model = model.replace("-thinking", "")
|
| 337 |
+
|
| 338 |
+
# 4. Claude 模型关键词映射
|
| 339 |
+
# 使用关键词匹配而不是精确匹配,更灵活地处理各种变体
|
| 340 |
+
original_model = model
|
| 341 |
+
if "opus" in model.lower():
|
| 342 |
+
model = "claude-opus-4-5-thinking"
|
| 343 |
+
elif "sonnet" in model.lower() or "haiku" in model.lower():
|
| 344 |
+
model = "claude-sonnet-4-5-thinking"
|
| 345 |
+
elif "claude" in model.lower():
|
| 346 |
+
# Claude 模型兜底:如果包含 claude 但不是 opus/sonnet/haiku
|
| 347 |
+
model = "claude-sonnet-4-5-thinking"
|
| 348 |
+
|
| 349 |
+
result["model"] = model
|
| 350 |
+
if original_model != model:
|
| 351 |
+
log.debug(f"[ANTIGRAVITY] 映射模型: {original_model} -> {model}")
|
| 352 |
+
|
| 353 |
+
# ========== 公共处理 ==========
|
| 354 |
+
# 1. 字段名转换
|
| 355 |
+
if "system_instructions" in result:
|
| 356 |
+
result["systemInstruction"] = result.pop("system_instructions")
|
| 357 |
+
|
| 358 |
+
# 2. 参数范围限制
|
| 359 |
+
if generation_config:
|
| 360 |
+
max_tokens = generation_config.get("maxOutputTokens")
|
| 361 |
+
if max_tokens is not None:
|
| 362 |
+
generation_config["maxOutputTokens"] = 64000
|
| 363 |
+
|
| 364 |
+
top_k = generation_config.get("topK")
|
| 365 |
+
if top_k is not None:
|
| 366 |
+
generation_config["topK"] = 64
|
| 367 |
+
|
| 368 |
+
# 3. 工具清理
|
| 369 |
+
if tools:
|
| 370 |
+
result["tools"] = clean_tools_for_gemini(tools)
|
| 371 |
+
|
| 372 |
+
# 4. 清理空的 parts 和未知字段(修复 400 错误:required oneof field 'data' must have one initialized field)
|
| 373 |
+
# 同时移除不支持的字段如 cache_control
|
| 374 |
+
if "contents" in result:
|
| 375 |
+
# 定义 part 中允许的字段集合
|
| 376 |
+
ALLOWED_PART_KEYS = {
|
| 377 |
+
"text", "inlineData", "fileData", "functionCall", "functionResponse",
|
| 378 |
+
"thought", "thoughtSignature" # thinking 相关字段
|
| 379 |
+
}
|
| 380 |
+
|
| 381 |
+
cleaned_contents = []
|
| 382 |
+
for content in result["contents"]:
|
| 383 |
+
if isinstance(content, dict) and "parts" in content:
|
| 384 |
+
# 过滤掉空的或无效的 parts,并移除未知字段
|
| 385 |
+
valid_parts = []
|
| 386 |
+
for part in content["parts"]:
|
| 387 |
+
if not isinstance(part, dict):
|
| 388 |
+
continue
|
| 389 |
+
|
| 390 |
+
# 移除不支持的字段(如 cache_control)
|
| 391 |
+
cleaned_part = {k: v for k, v in part.items() if k in ALLOWED_PART_KEYS}
|
| 392 |
+
|
| 393 |
+
# 检查 part 是否有有效的数据字段
|
| 394 |
+
has_valid_data = any(
|
| 395 |
+
key in cleaned_part and cleaned_part[key]
|
| 396 |
+
for key in ["text", "inlineData", "fileData", "functionCall", "functionResponse"]
|
| 397 |
+
)
|
| 398 |
+
if has_valid_data:
|
| 399 |
+
valid_parts.append(cleaned_part)
|
| 400 |
+
else:
|
| 401 |
+
log.warning(f"[GEMINI_FIX] 移除空的或无效的 part: {part}")
|
| 402 |
+
|
| 403 |
+
# 只添加有有效 parts 的 content
|
| 404 |
+
if valid_parts:
|
| 405 |
+
cleaned_content = content.copy()
|
| 406 |
+
cleaned_content["parts"] = valid_parts
|
| 407 |
+
cleaned_contents.append(cleaned_content)
|
| 408 |
+
else:
|
| 409 |
+
log.warning(f"[GEMINI_FIX] 跳过没有有效 parts 的 content: {content.get('role')}")
|
| 410 |
+
else:
|
| 411 |
+
cleaned_contents.append(content)
|
| 412 |
+
|
| 413 |
+
result["contents"] = cleaned_contents
|
| 414 |
+
|
| 415 |
+
if generation_config:
|
| 416 |
+
result["generationConfig"] = generation_config
|
| 417 |
+
|
| 418 |
+
return result
|
src/converter/openai2gemini.py
ADDED
|
@@ -0,0 +1,930 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
return None
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
# ==================== Tool Conversion Functions ====================
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def _normalize_function_name(name: str) -> str:
|
| 76 |
+
"""
|
| 77 |
+
规范化函数名以符合 Gemini API 要求
|
| 78 |
+
|
| 79 |
+
规则:
|
| 80 |
+
- 必须以字母或下划线开头
|
| 81 |
+
- 只能包含 a-z, A-Z, 0-9, 下划线, 点, 短横线
|
| 82 |
+
- 最大长度 64 个字符
|
| 83 |
+
|
| 84 |
+
转换策略:
|
| 85 |
+
- 中文字符转换为拼音
|
| 86 |
+
- 如果以非字母/下划线开头,添加 "_" 前缀
|
| 87 |
+
- 将非法字符(空格、@、#等)替换为下划线
|
| 88 |
+
- 连续的下划线合并为一个
|
| 89 |
+
- 如果超过 64 个字符,截断
|
| 90 |
+
|
| 91 |
+
Args:
|
| 92 |
+
name: 原始函数名
|
| 93 |
+
|
| 94 |
+
Returns:
|
| 95 |
+
规范化后的函数名
|
| 96 |
+
"""
|
| 97 |
+
import re
|
| 98 |
+
|
| 99 |
+
if not name:
|
| 100 |
+
return "_unnamed_function"
|
| 101 |
+
|
| 102 |
+
# 第零步:检测并转换中文字符为拼音
|
| 103 |
+
# 检查是否包含中文字符
|
| 104 |
+
if re.search(r"[\u4e00-\u9fff]", name):
|
| 105 |
+
try:
|
| 106 |
+
|
| 107 |
+
# 将中文转换为拼音,用下划线连接多音字
|
| 108 |
+
parts = []
|
| 109 |
+
for char in name:
|
| 110 |
+
if "\u4e00" <= char <= "\u9fff":
|
| 111 |
+
# 中文字符,转换为拼音
|
| 112 |
+
pinyin = lazy_pinyin(char, style=Style.NORMAL)
|
| 113 |
+
parts.append("".join(pinyin))
|
| 114 |
+
else:
|
| 115 |
+
# 非中文字符,保持不变
|
| 116 |
+
parts.append(char)
|
| 117 |
+
normalized = "".join(parts)
|
| 118 |
+
except ImportError:
|
| 119 |
+
log.warning("pypinyin not installed, cannot convert Chinese characters to pinyin")
|
| 120 |
+
normalized = name
|
| 121 |
+
else:
|
| 122 |
+
normalized = name
|
| 123 |
+
|
| 124 |
+
# 第一步:将非法字符替换为下划线
|
| 125 |
+
# 保留:a-z, A-Z, 0-9, 下划线, 点, 短横线
|
| 126 |
+
normalized = re.sub(r"[^a-zA-Z0-9_.\-]", "_", normalized)
|
| 127 |
+
|
| 128 |
+
# 第二步:如果以非字母/下划线开头,处理首字符
|
| 129 |
+
prefix_added = False
|
| 130 |
+
if normalized and not (normalized[0].isalpha() or normalized[0] == "_"):
|
| 131 |
+
if normalized[0] in ".-":
|
| 132 |
+
# 点和短横线在开头位置替换为下划线(它们在中间是合法的)
|
| 133 |
+
normalized = "_" + normalized[1:]
|
| 134 |
+
else:
|
| 135 |
+
# 其他字符(如数字)添加下划线前缀
|
| 136 |
+
normalized = "_" + normalized
|
| 137 |
+
prefix_added = True
|
| 138 |
+
|
| 139 |
+
# 第三步:合并连续的下划线
|
| 140 |
+
normalized = re.sub(r"_+", "_", normalized)
|
| 141 |
+
|
| 142 |
+
# 第四步:移除首尾的下划线
|
| 143 |
+
# 如果原本就是下划线开头,或者我们添加了前缀,则保留开头的下划线
|
| 144 |
+
if name.startswith("_") or prefix_added:
|
| 145 |
+
# 只移除尾部的下划线
|
| 146 |
+
normalized = normalized.rstrip("_")
|
| 147 |
+
else:
|
| 148 |
+
# 移除首尾的下划线
|
| 149 |
+
normalized = normalized.strip("_")
|
| 150 |
+
|
| 151 |
+
# 第五步:确保不为空
|
| 152 |
+
if not normalized:
|
| 153 |
+
normalized = "_unnamed_function"
|
| 154 |
+
|
| 155 |
+
# 第六步:截断到 64 个字符
|
| 156 |
+
if len(normalized) > 64:
|
| 157 |
+
normalized = normalized[:64]
|
| 158 |
+
|
| 159 |
+
return normalized
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
def _clean_schema_for_gemini(schema: Any) -> Any:
|
| 163 |
+
"""
|
| 164 |
+
清理 JSON Schema���移除 Gemini 不支持的字段
|
| 165 |
+
|
| 166 |
+
Gemini API 只支持有限的 OpenAPI 3.0 Schema 属性:
|
| 167 |
+
- 支持: type, description, enum, items, properties, required, nullable, format
|
| 168 |
+
- 不支持: $schema, $id, $ref, $defs, title, examples, default, readOnly,
|
| 169 |
+
exclusiveMaximum, exclusiveMinimum, oneOf, anyOf, allOf, const 等
|
| 170 |
+
|
| 171 |
+
Args:
|
| 172 |
+
schema: JSON Schema 对象(字典、列表或其他值)
|
| 173 |
+
|
| 174 |
+
Returns:
|
| 175 |
+
清理后的 schema
|
| 176 |
+
"""
|
| 177 |
+
if not isinstance(schema, dict):
|
| 178 |
+
return schema
|
| 179 |
+
|
| 180 |
+
# Gemini 不支持的字段
|
| 181 |
+
unsupported_keys = {
|
| 182 |
+
"$schema",
|
| 183 |
+
"$id",
|
| 184 |
+
"$ref",
|
| 185 |
+
"$defs",
|
| 186 |
+
"definitions",
|
| 187 |
+
"example",
|
| 188 |
+
"examples",
|
| 189 |
+
"readOnly",
|
| 190 |
+
"writeOnly",
|
| 191 |
+
"default",
|
| 192 |
+
"exclusiveMaximum",
|
| 193 |
+
"exclusiveMinimum",
|
| 194 |
+
"oneOf",
|
| 195 |
+
"anyOf",
|
| 196 |
+
"allOf",
|
| 197 |
+
"const",
|
| 198 |
+
"additionalItems",
|
| 199 |
+
"contains",
|
| 200 |
+
"patternProperties",
|
| 201 |
+
"dependencies",
|
| 202 |
+
"propertyNames",
|
| 203 |
+
"if",
|
| 204 |
+
"then",
|
| 205 |
+
"else",
|
| 206 |
+
"contentEncoding",
|
| 207 |
+
"contentMediaType",
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
+
cleaned = {}
|
| 211 |
+
for key, value in schema.items():
|
| 212 |
+
if key in unsupported_keys:
|
| 213 |
+
continue
|
| 214 |
+
if isinstance(value, dict):
|
| 215 |
+
cleaned[key] = _clean_schema_for_gemini(value)
|
| 216 |
+
elif isinstance(value, list):
|
| 217 |
+
cleaned[key] = [
|
| 218 |
+
_clean_schema_for_gemini(item) if isinstance(item, dict) else item for item in value
|
| 219 |
+
]
|
| 220 |
+
else:
|
| 221 |
+
cleaned[key] = value
|
| 222 |
+
|
| 223 |
+
# 确保有 type 字段(如果有 properties 但没有 type)
|
| 224 |
+
if "properties" in cleaned and "type" not in cleaned:
|
| 225 |
+
cleaned["type"] = "object"
|
| 226 |
+
|
| 227 |
+
return cleaned
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
def convert_openai_tools_to_gemini(openai_tools: List) -> List[Dict[str, Any]]:
|
| 231 |
+
"""
|
| 232 |
+
将 OpenAI tools 格式转换为 Gemini functionDeclarations 格式
|
| 233 |
+
|
| 234 |
+
Args:
|
| 235 |
+
openai_tools: OpenAI 格式的工具列表(可能是字典或 Pydantic 模型)
|
| 236 |
+
|
| 237 |
+
Returns:
|
| 238 |
+
Gemini 格式的工具列表
|
| 239 |
+
"""
|
| 240 |
+
if not openai_tools:
|
| 241 |
+
return []
|
| 242 |
+
|
| 243 |
+
function_declarations = []
|
| 244 |
+
|
| 245 |
+
for tool in openai_tools:
|
| 246 |
+
if tool.get("type") != "function":
|
| 247 |
+
log.warning(f"Skipping non-function tool type: {tool.get('type')}")
|
| 248 |
+
continue
|
| 249 |
+
|
| 250 |
+
function = tool.get("function")
|
| 251 |
+
if not function:
|
| 252 |
+
log.warning("Tool missing 'function' field")
|
| 253 |
+
continue
|
| 254 |
+
|
| 255 |
+
# 获取并规范化函数名
|
| 256 |
+
original_name = function.get("name")
|
| 257 |
+
if not original_name:
|
| 258 |
+
log.warning("Tool missing 'name' field, using default")
|
| 259 |
+
original_name = "_unnamed_function"
|
| 260 |
+
|
| 261 |
+
normalized_name = _normalize_function_name(original_name)
|
| 262 |
+
|
| 263 |
+
# 如果名称被修改了,记录日志
|
| 264 |
+
if normalized_name != original_name:
|
| 265 |
+
log.debug(f"Function name normalized: '{original_name}' -> '{normalized_name}'")
|
| 266 |
+
|
| 267 |
+
# 构建 Gemini function declaration
|
| 268 |
+
declaration = {
|
| 269 |
+
"name": normalized_name,
|
| 270 |
+
"description": function.get("description", ""),
|
| 271 |
+
}
|
| 272 |
+
|
| 273 |
+
# 添加参数(如果有)- 清理不支持的 schema 字段
|
| 274 |
+
if "parameters" in function:
|
| 275 |
+
cleaned_params = _clean_schema_for_gemini(function["parameters"])
|
| 276 |
+
if cleaned_params:
|
| 277 |
+
declaration["parameters"] = cleaned_params
|
| 278 |
+
|
| 279 |
+
function_declarations.append(declaration)
|
| 280 |
+
|
| 281 |
+
if not function_declarations:
|
| 282 |
+
return []
|
| 283 |
+
|
| 284 |
+
# Gemini 格式:工具数组中包含 functionDeclarations
|
| 285 |
+
return [{"functionDeclarations": function_declarations}]
|
| 286 |
+
|
| 287 |
+
|
| 288 |
+
def convert_tool_choice_to_tool_config(tool_choice: Union[str, Dict[str, Any]]) -> Dict[str, Any]:
|
| 289 |
+
"""
|
| 290 |
+
将 OpenAI tool_choice 转换为 Gemini toolConfig
|
| 291 |
+
|
| 292 |
+
Args:
|
| 293 |
+
tool_choice: OpenAI 格式的 tool_choice
|
| 294 |
+
|
| 295 |
+
Returns:
|
| 296 |
+
Gemini 格式的 toolConfig
|
| 297 |
+
"""
|
| 298 |
+
if isinstance(tool_choice, str):
|
| 299 |
+
if tool_choice == "auto":
|
| 300 |
+
return {"functionCallingConfig": {"mode": "AUTO"}}
|
| 301 |
+
elif tool_choice == "none":
|
| 302 |
+
return {"functionCallingConfig": {"mode": "NONE"}}
|
| 303 |
+
elif tool_choice == "required":
|
| 304 |
+
return {"functionCallingConfig": {"mode": "ANY"}}
|
| 305 |
+
elif isinstance(tool_choice, dict):
|
| 306 |
+
# {"type": "function", "function": {"name": "my_function"}}
|
| 307 |
+
if tool_choice.get("type") == "function":
|
| 308 |
+
function_name = tool_choice.get("function", {}).get("name")
|
| 309 |
+
if function_name:
|
| 310 |
+
return {
|
| 311 |
+
"functionCallingConfig": {
|
| 312 |
+
"mode": "ANY",
|
| 313 |
+
"allowedFunctionNames": [function_name],
|
| 314 |
+
}
|
| 315 |
+
}
|
| 316 |
+
|
| 317 |
+
# 默认返回 AUTO 模式
|
| 318 |
+
return {"functionCallingConfig": {"mode": "AUTO"}}
|
| 319 |
+
|
| 320 |
+
|
| 321 |
+
def convert_tool_message_to_function_response(message, all_messages: List = None) -> Dict[str, Any]:
|
| 322 |
+
"""
|
| 323 |
+
将 OpenAI 的 tool role 消息转换为 Gemini functionResponse
|
| 324 |
+
|
| 325 |
+
Args:
|
| 326 |
+
message: OpenAI 格式的工具消息
|
| 327 |
+
all_messages: 所有消息的列表,用于查找 tool_call_id 对应的函数名
|
| 328 |
+
|
| 329 |
+
Returns:
|
| 330 |
+
Gemini 格式的 functionResponse part
|
| 331 |
+
"""
|
| 332 |
+
# 获取 name 字段
|
| 333 |
+
name = getattr(message, "name", None)
|
| 334 |
+
encoded_tool_call_id = getattr(message, "tool_call_id", None) or ""
|
| 335 |
+
|
| 336 |
+
# 解码获取原始ID(functionResponse不需要签名)
|
| 337 |
+
original_tool_call_id, _ = decode_tool_id_and_signature(encoded_tool_call_id)
|
| 338 |
+
|
| 339 |
+
# 如果没有 name,尝试从 all_messages 中查找对应的 tool_call_id
|
| 340 |
+
# 注意:使用编码ID查找,因为存储的是编码ID
|
| 341 |
+
if not name and encoded_tool_call_id and all_messages:
|
| 342 |
+
for msg in all_messages:
|
| 343 |
+
if getattr(msg, "role", None) == "assistant" and hasattr(msg, "tool_calls") and msg.tool_calls:
|
| 344 |
+
for tool_call in msg.tool_calls:
|
| 345 |
+
if getattr(tool_call, "id", None) == encoded_tool_call_id:
|
| 346 |
+
func = getattr(tool_call, "function", None)
|
| 347 |
+
if func:
|
| 348 |
+
name = getattr(func, "name", None)
|
| 349 |
+
break
|
| 350 |
+
if name:
|
| 351 |
+
break
|
| 352 |
+
|
| 353 |
+
# 最终兜底:如果仍然没有 name,使用默认值
|
| 354 |
+
if not name:
|
| 355 |
+
name = "unknown_function"
|
| 356 |
+
log.warning(f"Tool message missing function name, using default: {name}")
|
| 357 |
+
|
| 358 |
+
try:
|
| 359 |
+
# 尝试将 content 解析为 JSON
|
| 360 |
+
response_data = (
|
| 361 |
+
json.loads(message.content) if isinstance(message.content, str) else message.content
|
| 362 |
+
)
|
| 363 |
+
except (json.JSONDecodeError, TypeError):
|
| 364 |
+
# 如果不是有效的 JSON,包装为对象
|
| 365 |
+
response_data = {"result": str(message.content)}
|
| 366 |
+
|
| 367 |
+
return {"functionResponse": {"id": original_tool_call_id, "name": name, "response": response_data}}
|
| 368 |
+
|
| 369 |
+
|
| 370 |
+
def extract_tool_calls_from_parts(
|
| 371 |
+
parts: List[Dict[str, Any]], is_streaming: bool = False
|
| 372 |
+
) -> Tuple[List[Dict[str, Any]], str]:
|
| 373 |
+
"""
|
| 374 |
+
从 Gemini response parts 中提取工具调用和文本内容
|
| 375 |
+
|
| 376 |
+
Args:
|
| 377 |
+
parts: Gemini response 的 parts 数组
|
| 378 |
+
is_streaming: 是否为流式响应(流式响应需要添加 index 字段)
|
| 379 |
+
|
| 380 |
+
Returns:
|
| 381 |
+
(tool_calls, text_content) 元组
|
| 382 |
+
"""
|
| 383 |
+
tool_calls = []
|
| 384 |
+
text_content = ""
|
| 385 |
+
|
| 386 |
+
for idx, part in enumerate(parts):
|
| 387 |
+
# 检查是否是函数调用
|
| 388 |
+
if "functionCall" in part:
|
| 389 |
+
function_call = part["functionCall"]
|
| 390 |
+
# 获取原始ID或生成新ID
|
| 391 |
+
original_id = function_call.get("id") or f"call_{uuid.uuid4().hex[:24]}"
|
| 392 |
+
# 将thoughtSignature编码到ID中以便往返保留
|
| 393 |
+
signature = part.get("thoughtSignature")
|
| 394 |
+
encoded_id = encode_tool_id_with_signature(original_id, signature)
|
| 395 |
+
|
| 396 |
+
tool_call = {
|
| 397 |
+
"id": encoded_id,
|
| 398 |
+
"type": "function",
|
| 399 |
+
"function": {
|
| 400 |
+
"name": function_call.get("name", "nameless_function"),
|
| 401 |
+
"arguments": json.dumps(function_call.get("args", {})),
|
| 402 |
+
},
|
| 403 |
+
}
|
| 404 |
+
# 流式响应需要 index 字段
|
| 405 |
+
if is_streaming:
|
| 406 |
+
tool_call["index"] = idx
|
| 407 |
+
tool_calls.append(tool_call)
|
| 408 |
+
|
| 409 |
+
# 提取文本内容(排除 thinking tokens)
|
| 410 |
+
elif "text" in part and not part.get("thought", False):
|
| 411 |
+
text_content += part["text"]
|
| 412 |
+
|
| 413 |
+
return tool_calls, text_content
|
| 414 |
+
|
| 415 |
+
|
| 416 |
+
def extract_images_from_content(content: Any) -> Dict[str, Any]:
|
| 417 |
+
"""
|
| 418 |
+
从 OpenAI content 中提取文本和图片
|
| 419 |
+
|
| 420 |
+
Args:
|
| 421 |
+
content: OpenAI 消息的 content 字段(可能是字符串或列表)
|
| 422 |
+
|
| 423 |
+
Returns:
|
| 424 |
+
包含 text 和 images 的字典
|
| 425 |
+
"""
|
| 426 |
+
result = {"text": "", "images": []}
|
| 427 |
+
|
| 428 |
+
if isinstance(content, str):
|
| 429 |
+
result["text"] = content
|
| 430 |
+
elif isinstance(content, list):
|
| 431 |
+
for item in content:
|
| 432 |
+
if isinstance(item, dict):
|
| 433 |
+
if item.get("type") == "text":
|
| 434 |
+
result["text"] += item.get("text", "")
|
| 435 |
+
elif item.get("type") == "image_url":
|
| 436 |
+
image_url = item.get("image_url", {}).get("url", "")
|
| 437 |
+
# 解析 data:image/png;base64,xxx 格式
|
| 438 |
+
if image_url.startswith("data:image/"):
|
| 439 |
+
import re
|
| 440 |
+
match = re.match(r"^data:image/(\w+);base64,(.+)$", image_url)
|
| 441 |
+
if match:
|
| 442 |
+
mime_type = match.group(1)
|
| 443 |
+
base64_data = match.group(2)
|
| 444 |
+
result["images"].append({
|
| 445 |
+
"inlineData": {
|
| 446 |
+
"mimeType": f"image/{mime_type}",
|
| 447 |
+
"data": base64_data
|
| 448 |
+
}
|
| 449 |
+
})
|
| 450 |
+
|
| 451 |
+
return result
|
| 452 |
+
|
| 453 |
+
async def convert_openai_to_gemini_request(openai_request: Dict[str, Any]) -> Dict[str, Any]:
|
| 454 |
+
"""
|
| 455 |
+
将 OpenAI 格式请求体转换为 Gemini 格式请求体
|
| 456 |
+
|
| 457 |
+
注意: 此函数只负责基础转换,不包含 normalize_gemini_request 中的处理
|
| 458 |
+
(如 thinking config, search tools, 参数范围限制等)
|
| 459 |
+
|
| 460 |
+
Args:
|
| 461 |
+
openai_request: OpenAI 格式的请求体字典,包含:
|
| 462 |
+
- messages: 消息列表
|
| 463 |
+
- temperature, top_p, max_tokens, stop 等生成参数
|
| 464 |
+
- tools, tool_choice (可选)
|
| 465 |
+
- response_format (可选)
|
| 466 |
+
|
| 467 |
+
Returns:
|
| 468 |
+
Gemini 格式的请求体字典,包含:
|
| 469 |
+
- contents: 转换后的消息内容
|
| 470 |
+
- generationConfig: 生成配置
|
| 471 |
+
- systemInstruction: 系统指令 (如果有)
|
| 472 |
+
- tools, toolConfig (如果有)
|
| 473 |
+
"""
|
| 474 |
+
# 处理连续的system消息(兼容性模式)
|
| 475 |
+
openai_request = await merge_system_messages(openai_request)
|
| 476 |
+
|
| 477 |
+
contents = []
|
| 478 |
+
|
| 479 |
+
# 提取消息列表
|
| 480 |
+
messages = openai_request.get("messages", [])
|
| 481 |
+
|
| 482 |
+
for message in messages:
|
| 483 |
+
role = message.get("role", "user")
|
| 484 |
+
content = message.get("content", "")
|
| 485 |
+
|
| 486 |
+
# 处理工具消息(tool role)
|
| 487 |
+
if role == "tool":
|
| 488 |
+
tool_call_id = message.get("tool_call_id", "")
|
| 489 |
+
func_name = message.get("name")
|
| 490 |
+
|
| 491 |
+
# 如果没有name,尝试从消息列表中查找
|
| 492 |
+
if not func_name and tool_call_id:
|
| 493 |
+
for msg in messages:
|
| 494 |
+
if msg.get("role") == "assistant" and msg.get("tool_calls"):
|
| 495 |
+
for tc in msg["tool_calls"]:
|
| 496 |
+
if tc.get("id") == tool_call_id:
|
| 497 |
+
func_name = tc.get("function", {}).get("name")
|
| 498 |
+
break
|
| 499 |
+
if func_name:
|
| 500 |
+
break
|
| 501 |
+
|
| 502 |
+
if not func_name:
|
| 503 |
+
func_name = "unknown_function"
|
| 504 |
+
|
| 505 |
+
# 解析响应数据
|
| 506 |
+
try:
|
| 507 |
+
response_data = json.loads(content) if isinstance(content, str) else content
|
| 508 |
+
except (json.JSONDecodeError, TypeError):
|
| 509 |
+
response_data = {"result": str(content)}
|
| 510 |
+
|
| 511 |
+
contents.append({
|
| 512 |
+
"role": "user",
|
| 513 |
+
"parts": [{
|
| 514 |
+
"functionResponse": {
|
| 515 |
+
"id": tool_call_id,
|
| 516 |
+
"name": func_name,
|
| 517 |
+
"response": response_data
|
| 518 |
+
}
|
| 519 |
+
}]
|
| 520 |
+
})
|
| 521 |
+
continue
|
| 522 |
+
|
| 523 |
+
# system 消息已经由 merge_system_messages 处理,这里跳过
|
| 524 |
+
if role == "system":
|
| 525 |
+
continue
|
| 526 |
+
|
| 527 |
+
# 将OpenAI角色映射到Gemini角色
|
| 528 |
+
if role == "assistant":
|
| 529 |
+
role = "model"
|
| 530 |
+
|
| 531 |
+
# 检查是否有tool_calls
|
| 532 |
+
tool_calls = message.get("tool_calls")
|
| 533 |
+
if tool_calls:
|
| 534 |
+
parts = []
|
| 535 |
+
|
| 536 |
+
# 如果有文本内容,先添加文本
|
| 537 |
+
if content:
|
| 538 |
+
parts.append({"text": content})
|
| 539 |
+
|
| 540 |
+
# 添加每个工具调用
|
| 541 |
+
for tool_call in tool_calls:
|
| 542 |
+
try:
|
| 543 |
+
args = (
|
| 544 |
+
json.loads(tool_call["function"]["arguments"])
|
| 545 |
+
if isinstance(tool_call["function"]["arguments"], str)
|
| 546 |
+
else tool_call["function"]["arguments"]
|
| 547 |
+
)
|
| 548 |
+
|
| 549 |
+
# 解码工具ID和thoughtSignature
|
| 550 |
+
encoded_id = tool_call.get("id", "")
|
| 551 |
+
original_id, signature = decode_tool_id_and_signature(encoded_id)
|
| 552 |
+
|
| 553 |
+
# 构建functionCall part
|
| 554 |
+
function_call_part = {
|
| 555 |
+
"functionCall": {
|
| 556 |
+
"id": original_id,
|
| 557 |
+
"name": tool_call["function"]["name"],
|
| 558 |
+
"args": args
|
| 559 |
+
}
|
| 560 |
+
}
|
| 561 |
+
|
| 562 |
+
# 如果有thoughtSignature,添加到part中
|
| 563 |
+
if signature:
|
| 564 |
+
function_call_part["thoughtSignature"] = signature
|
| 565 |
+
|
| 566 |
+
parts.append(function_call_part)
|
| 567 |
+
except (json.JSONDecodeError, KeyError) as e:
|
| 568 |
+
log.error(f"Failed to parse tool call: {e}")
|
| 569 |
+
continue
|
| 570 |
+
|
| 571 |
+
if parts:
|
| 572 |
+
contents.append({"role": role, "parts": parts})
|
| 573 |
+
continue
|
| 574 |
+
|
| 575 |
+
# 处理普通内容
|
| 576 |
+
if isinstance(content, list):
|
| 577 |
+
parts = []
|
| 578 |
+
for part in content:
|
| 579 |
+
if part.get("type") == "text":
|
| 580 |
+
parts.append({"text": part.get("text", "")})
|
| 581 |
+
elif part.get("type") == "image_url":
|
| 582 |
+
image_url = part.get("image_url", {}).get("url")
|
| 583 |
+
if image_url:
|
| 584 |
+
try:
|
| 585 |
+
mime_type, base64_data = image_url.split(";")
|
| 586 |
+
_, mime_type = mime_type.split(":")
|
| 587 |
+
_, base64_data = base64_data.split(",")
|
| 588 |
+
parts.append({
|
| 589 |
+
"inlineData": {
|
| 590 |
+
"mimeType": mime_type,
|
| 591 |
+
"data": base64_data,
|
| 592 |
+
}
|
| 593 |
+
})
|
| 594 |
+
except ValueError:
|
| 595 |
+
continue
|
| 596 |
+
if parts:
|
| 597 |
+
contents.append({"role": role, "parts": parts})
|
| 598 |
+
elif content:
|
| 599 |
+
contents.append({"role": role, "parts": [{"text": content}]})
|
| 600 |
+
|
| 601 |
+
# 构建生成配置
|
| 602 |
+
generation_config = {}
|
| 603 |
+
if "temperature" in openai_request:
|
| 604 |
+
generation_config["temperature"] = openai_request["temperature"]
|
| 605 |
+
if "top_p" in openai_request:
|
| 606 |
+
generation_config["topP"] = openai_request["top_p"]
|
| 607 |
+
if "max_tokens" in openai_request:
|
| 608 |
+
generation_config["maxOutputTokens"] = openai_request["max_tokens"]
|
| 609 |
+
if "stop" in openai_request:
|
| 610 |
+
stop = openai_request["stop"]
|
| 611 |
+
generation_config["stopSequences"] = [stop] if isinstance(stop, str) else stop
|
| 612 |
+
if "frequency_penalty" in openai_request:
|
| 613 |
+
generation_config["frequencyPenalty"] = openai_request["frequency_penalty"]
|
| 614 |
+
if "presence_penalty" in openai_request:
|
| 615 |
+
generation_config["presencePenalty"] = openai_request["presence_penalty"]
|
| 616 |
+
if "n" in openai_request:
|
| 617 |
+
generation_config["candidateCount"] = openai_request["n"]
|
| 618 |
+
if "seed" in openai_request:
|
| 619 |
+
generation_config["seed"] = openai_request["seed"]
|
| 620 |
+
if "response_format" in openai_request and openai_request["response_format"]:
|
| 621 |
+
if openai_request["response_format"].get("type") == "json_object":
|
| 622 |
+
generation_config["responseMimeType"] = "application/json"
|
| 623 |
+
|
| 624 |
+
# 如果contents为空,添加默认用户消息
|
| 625 |
+
if not contents:
|
| 626 |
+
contents.append({"role": "user", "parts": [{"text": "请根据系统指令回答。"}]})
|
| 627 |
+
|
| 628 |
+
# 构建基础请求
|
| 629 |
+
gemini_request = {
|
| 630 |
+
"contents": contents,
|
| 631 |
+
"generationConfig": generation_config
|
| 632 |
+
}
|
| 633 |
+
|
| 634 |
+
# 如果 merge_system_messages 已经添加了 systemInstruction,使用它
|
| 635 |
+
if "systemInstruction" in openai_request:
|
| 636 |
+
gemini_request["systemInstruction"] = openai_request["systemInstruction"]
|
| 637 |
+
|
| 638 |
+
# 处理工具
|
| 639 |
+
if "tools" in openai_request and openai_request["tools"]:
|
| 640 |
+
gemini_request["tools"] = convert_openai_tools_to_gemini(openai_request["tools"])
|
| 641 |
+
|
| 642 |
+
# 处理tool_choice
|
| 643 |
+
if "tool_choice" in openai_request and openai_request["tool_choice"]:
|
| 644 |
+
gemini_request["toolConfig"] = convert_tool_choice_to_tool_config(openai_request["tool_choice"])
|
| 645 |
+
|
| 646 |
+
return gemini_request
|
| 647 |
+
|
| 648 |
+
|
| 649 |
+
def convert_gemini_to_openai_response(
|
| 650 |
+
gemini_response: Union[Dict[str, Any], Any],
|
| 651 |
+
model: str,
|
| 652 |
+
status_code: int = 200
|
| 653 |
+
) -> Dict[str, Any]:
|
| 654 |
+
"""
|
| 655 |
+
将 Gemini 格式非流式响应转换为 OpenAI 格式非流式响应
|
| 656 |
+
|
| 657 |
+
注意: 如果收到的不是 200 开头的响应,不做任何处理,直接转发原始响应
|
| 658 |
+
|
| 659 |
+
Args:
|
| 660 |
+
gemini_response: Gemini 格式的响应体 (字典或响应对象)
|
| 661 |
+
model: 模型名称
|
| 662 |
+
status_code: HTTP 状态码 (默认 200)
|
| 663 |
+
|
| 664 |
+
Returns:
|
| 665 |
+
OpenAI 格式的响应体字典,或原始响应 (如果状态码不是 2xx)
|
| 666 |
+
"""
|
| 667 |
+
# 非 2xx 状态码直接返回原始响应
|
| 668 |
+
if not (200 <= status_code < 300):
|
| 669 |
+
if isinstance(gemini_response, dict):
|
| 670 |
+
return gemini_response
|
| 671 |
+
else:
|
| 672 |
+
# 如果是响应对象,尝试解析为字典
|
| 673 |
+
try:
|
| 674 |
+
if hasattr(gemini_response, "json"):
|
| 675 |
+
return gemini_response.json()
|
| 676 |
+
elif hasattr(gemini_response, "body"):
|
| 677 |
+
body = gemini_response.body
|
| 678 |
+
if isinstance(body, bytes):
|
| 679 |
+
return json.loads(body.decode())
|
| 680 |
+
return json.loads(str(body))
|
| 681 |
+
else:
|
| 682 |
+
return {"error": str(gemini_response)}
|
| 683 |
+
except:
|
| 684 |
+
return {"error": str(gemini_response)}
|
| 685 |
+
|
| 686 |
+
# 确保是字典格式
|
| 687 |
+
if not isinstance(gemini_response, dict):
|
| 688 |
+
try:
|
| 689 |
+
if hasattr(gemini_response, "json"):
|
| 690 |
+
gemini_response = gemini_response.json()
|
| 691 |
+
elif hasattr(gemini_response, "body"):
|
| 692 |
+
body = gemini_response.body
|
| 693 |
+
if isinstance(body, bytes):
|
| 694 |
+
gemini_response = json.loads(body.decode())
|
| 695 |
+
else:
|
| 696 |
+
gemini_response = json.loads(str(body))
|
| 697 |
+
else:
|
| 698 |
+
gemini_response = json.loads(str(gemini_response))
|
| 699 |
+
except:
|
| 700 |
+
return {"error": "Invalid response format"}
|
| 701 |
+
|
| 702 |
+
# 处理 GeminiCLI 的 response 包装格式
|
| 703 |
+
if "response" in gemini_response:
|
| 704 |
+
gemini_response = gemini_response["response"]
|
| 705 |
+
|
| 706 |
+
# 转换为 OpenAI 格式
|
| 707 |
+
choices = []
|
| 708 |
+
|
| 709 |
+
for candidate in gemini_response.get("candidates", []):
|
| 710 |
+
role = candidate.get("content", {}).get("role", "assistant")
|
| 711 |
+
|
| 712 |
+
# 将Gemini角色映射回OpenAI角色
|
| 713 |
+
if role == "model":
|
| 714 |
+
role = "assistant"
|
| 715 |
+
|
| 716 |
+
# 提取并分离thinking tokens和常规内容
|
| 717 |
+
parts = candidate.get("content", {}).get("parts", [])
|
| 718 |
+
|
| 719 |
+
# 提取工具调用和文本内容
|
| 720 |
+
tool_calls, text_content = extract_tool_calls_from_parts(parts)
|
| 721 |
+
|
| 722 |
+
# 提取图片数据
|
| 723 |
+
images = []
|
| 724 |
+
for part in parts:
|
| 725 |
+
if "inlineData" in part:
|
| 726 |
+
inline_data = part["inlineData"]
|
| 727 |
+
mime_type = inline_data.get("mimeType", "image/png")
|
| 728 |
+
base64_data = inline_data.get("data", "")
|
| 729 |
+
images.append({
|
| 730 |
+
"type": "image_url",
|
| 731 |
+
"image_url": {
|
| 732 |
+
"url": f"data:{mime_type};base64,{base64_data}"
|
| 733 |
+
}
|
| 734 |
+
})
|
| 735 |
+
|
| 736 |
+
# 提取 reasoning content
|
| 737 |
+
reasoning_content = ""
|
| 738 |
+
for part in parts:
|
| 739 |
+
if part.get("thought", False) and "text" in part:
|
| 740 |
+
reasoning_content += part["text"]
|
| 741 |
+
|
| 742 |
+
# 构建消息对象
|
| 743 |
+
message = {"role": role}
|
| 744 |
+
|
| 745 |
+
# 如果有工具调用
|
| 746 |
+
if tool_calls:
|
| 747 |
+
message["tool_calls"] = tool_calls
|
| 748 |
+
message["content"] = text_content if text_content else None
|
| 749 |
+
finish_reason = "tool_calls"
|
| 750 |
+
# 如果有图片
|
| 751 |
+
elif images:
|
| 752 |
+
content_list = []
|
| 753 |
+
if text_content:
|
| 754 |
+
content_list.append({"type": "text", "text": text_content})
|
| 755 |
+
content_list.extend(images)
|
| 756 |
+
message["content"] = content_list
|
| 757 |
+
finish_reason = _map_finish_reason(candidate.get("finishReason"))
|
| 758 |
+
else:
|
| 759 |
+
message["content"] = text_content
|
| 760 |
+
finish_reason = _map_finish_reason(candidate.get("finishReason"))
|
| 761 |
+
|
| 762 |
+
# 添加 reasoning content (如果有)
|
| 763 |
+
if reasoning_content:
|
| 764 |
+
message["reasoning_content"] = reasoning_content
|
| 765 |
+
|
| 766 |
+
choices.append({
|
| 767 |
+
"index": candidate.get("index", 0),
|
| 768 |
+
"message": message,
|
| 769 |
+
"finish_reason": finish_reason,
|
| 770 |
+
})
|
| 771 |
+
|
| 772 |
+
# 转换 usageMetadata
|
| 773 |
+
usage = _convert_usage_metadata(gemini_response.get("usageMetadata"))
|
| 774 |
+
|
| 775 |
+
response_data = {
|
| 776 |
+
"id": str(uuid.uuid4()),
|
| 777 |
+
"object": "chat.completion",
|
| 778 |
+
"created": int(time.time()),
|
| 779 |
+
"model": model,
|
| 780 |
+
"choices": choices,
|
| 781 |
+
}
|
| 782 |
+
|
| 783 |
+
if usage:
|
| 784 |
+
response_data["usage"] = usage
|
| 785 |
+
|
| 786 |
+
return response_data
|
| 787 |
+
|
| 788 |
+
|
| 789 |
+
def convert_gemini_to_openai_stream(
|
| 790 |
+
gemini_stream_chunk: str,
|
| 791 |
+
model: str,
|
| 792 |
+
response_id: str,
|
| 793 |
+
status_code: int = 200
|
| 794 |
+
) -> Optional[str]:
|
| 795 |
+
"""
|
| 796 |
+
将 Gemini 格式流式响应块转换为 OpenAI SSE 格式流式响应
|
| 797 |
+
|
| 798 |
+
注意: 如果收到的不是 200 开头的响应,不做任何处理,直接转发原始内容
|
| 799 |
+
|
| 800 |
+
Args:
|
| 801 |
+
gemini_stream_chunk: Gemini 格式的流式响应块 (字符串,通常是 "data: {json}" 格式)
|
| 802 |
+
model: 模型名称
|
| 803 |
+
response_id: 此流式响应的一致ID
|
| 804 |
+
status_code: HTTP 状态码 (默认 200)
|
| 805 |
+
|
| 806 |
+
Returns:
|
| 807 |
+
OpenAI SSE 格式的响应字符串 (如 "data: {json}\n\n"),
|
| 808 |
+
或原始内容 (如果状态码不是 2xx),
|
| 809 |
+
或 None (如果解析失败)
|
| 810 |
+
"""
|
| 811 |
+
# 非 2xx 状态码直接返回原始内容
|
| 812 |
+
if not (200 <= status_code < 300):
|
| 813 |
+
return gemini_stream_chunk
|
| 814 |
+
|
| 815 |
+
# 解析 Gemini 流式块
|
| 816 |
+
try:
|
| 817 |
+
# 去除 "data: " 前缀
|
| 818 |
+
if isinstance(gemini_stream_chunk, bytes):
|
| 819 |
+
if gemini_stream_chunk.startswith(b"data: "):
|
| 820 |
+
payload_str = gemini_stream_chunk[len(b"data: "):].strip().decode("utf-8")
|
| 821 |
+
else:
|
| 822 |
+
payload_str = gemini_stream_chunk.strip().decode("utf-8")
|
| 823 |
+
else:
|
| 824 |
+
if gemini_stream_chunk.startswith("data: "):
|
| 825 |
+
payload_str = gemini_stream_chunk[len("data: "):].strip()
|
| 826 |
+
else:
|
| 827 |
+
payload_str = gemini_stream_chunk.strip()
|
| 828 |
+
|
| 829 |
+
# 跳过空块
|
| 830 |
+
if not payload_str:
|
| 831 |
+
return None
|
| 832 |
+
|
| 833 |
+
# 解析 JSON
|
| 834 |
+
gemini_chunk = json.loads(payload_str)
|
| 835 |
+
except (json.JSONDecodeError, UnicodeDecodeError):
|
| 836 |
+
# 解析失败,跳过此块
|
| 837 |
+
return None
|
| 838 |
+
|
| 839 |
+
# 处理 GeminiCLI 的 response 包装格式
|
| 840 |
+
if "response" in gemini_chunk:
|
| 841 |
+
gemini_response = gemini_chunk["response"]
|
| 842 |
+
else:
|
| 843 |
+
gemini_response = gemini_chunk
|
| 844 |
+
|
| 845 |
+
# 转换为 OpenAI 流式格式
|
| 846 |
+
choices = []
|
| 847 |
+
|
| 848 |
+
for candidate in gemini_response.get("candidates", []):
|
| 849 |
+
role = candidate.get("content", {}).get("role", "assistant")
|
| 850 |
+
|
| 851 |
+
# 将Gemini角色映射回OpenAI角色
|
| 852 |
+
if role == "model":
|
| 853 |
+
role = "assistant"
|
| 854 |
+
|
| 855 |
+
# 提取并分离thinking tokens和常规内容
|
| 856 |
+
parts = candidate.get("content", {}).get("parts", [])
|
| 857 |
+
|
| 858 |
+
# 提取工具调用和文本内容 (流式需要 index)
|
| 859 |
+
tool_calls, text_content = extract_tool_calls_from_parts(parts, is_streaming=True)
|
| 860 |
+
|
| 861 |
+
# 提取图片数据
|
| 862 |
+
images = []
|
| 863 |
+
for part in parts:
|
| 864 |
+
if "inlineData" in part:
|
| 865 |
+
inline_data = part["inlineData"]
|
| 866 |
+
mime_type = inline_data.get("mimeType", "image/png")
|
| 867 |
+
base64_data = inline_data.get("data", "")
|
| 868 |
+
images.append({
|
| 869 |
+
"type": "image_url",
|
| 870 |
+
"image_url": {
|
| 871 |
+
"url": f"data:{mime_type};base64,{base64_data}"
|
| 872 |
+
}
|
| 873 |
+
})
|
| 874 |
+
|
| 875 |
+
# 提取 reasoning content
|
| 876 |
+
reasoning_content = ""
|
| 877 |
+
for part in parts:
|
| 878 |
+
if part.get("thought", False) and "text" in part:
|
| 879 |
+
reasoning_content += part["text"]
|
| 880 |
+
|
| 881 |
+
# 构建 delta 对象
|
| 882 |
+
delta = {}
|
| 883 |
+
|
| 884 |
+
if tool_calls:
|
| 885 |
+
delta["tool_calls"] = tool_calls
|
| 886 |
+
if text_content:
|
| 887 |
+
delta["content"] = text_content
|
| 888 |
+
elif images:
|
| 889 |
+
# 流式响应中的图片: 以 markdown 格式返回
|
| 890 |
+
markdown_images = [f"" for img in images]
|
| 891 |
+
if text_content:
|
| 892 |
+
delta["content"] = text_content + "\n\n" + "\n\n".join(markdown_images)
|
| 893 |
+
else:
|
| 894 |
+
delta["content"] = "\n\n".join(markdown_images)
|
| 895 |
+
elif text_content:
|
| 896 |
+
delta["content"] = text_content
|
| 897 |
+
|
| 898 |
+
if reasoning_content:
|
| 899 |
+
delta["reasoning_content"] = reasoning_content
|
| 900 |
+
|
| 901 |
+
finish_reason = _map_finish_reason(candidate.get("finishReason"))
|
| 902 |
+
if finish_reason and tool_calls:
|
| 903 |
+
finish_reason = "tool_calls"
|
| 904 |
+
|
| 905 |
+
choices.append({
|
| 906 |
+
"index": candidate.get("index", 0),
|
| 907 |
+
"delta": delta,
|
| 908 |
+
"finish_reason": finish_reason,
|
| 909 |
+
})
|
| 910 |
+
|
| 911 |
+
# 转换 usageMetadata (只在流结束时存在)
|
| 912 |
+
usage = _convert_usage_metadata(gemini_response.get("usageMetadata"))
|
| 913 |
+
|
| 914 |
+
# 构建 OpenAI 流式响应
|
| 915 |
+
response_data = {
|
| 916 |
+
"id": response_id,
|
| 917 |
+
"object": "chat.completion.chunk",
|
| 918 |
+
"created": int(time.time()),
|
| 919 |
+
"model": model,
|
| 920 |
+
"choices": choices,
|
| 921 |
+
}
|
| 922 |
+
|
| 923 |
+
# 只在有 usage 数据且有 finish_reason 时添加 usage
|
| 924 |
+
if usage:
|
| 925 |
+
has_finish_reason = any(choice.get("finish_reason") for choice in choices)
|
| 926 |
+
if has_finish_reason:
|
| 927 |
+
response_data["usage"] = usage
|
| 928 |
+
|
| 929 |
+
# 转换为 SSE 格式: "data: {json}\n\n"
|
| 930 |
+
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,231 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 and "systemInstruction" not in request_body:
|
| 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 |
+
remaining_messages.append(message)
|
| 217 |
+
|
| 218 |
+
# 如果没有找到任何system消息(包括顶层参数和messages中的),返回原始请求体
|
| 219 |
+
if not system_parts:
|
| 220 |
+
return request_body
|
| 221 |
+
|
| 222 |
+
# 构建新的请求体
|
| 223 |
+
result = request_body.copy()
|
| 224 |
+
|
| 225 |
+
# 添加或更新systemInstruction
|
| 226 |
+
result["systemInstruction"] = {"parts": system_parts}
|
| 227 |
+
|
| 228 |
+
# 更新messages列表(移除已处理的system消息)
|
| 229 |
+
result["messages"] = remaining_messages
|
| 230 |
+
|
| 231 |
+
return result
|
src/credential_manager.py
ADDED
|
@@ -0,0 +1,521 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 .google_oauth_api import Credentials
|
| 13 |
+
from .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 |
+
self._operation_lock = asyncio.Lock()
|
| 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 |
+
async with self._operation_lock:
|
| 37 |
+
if self._initialized and self._storage_adapter is not None:
|
| 38 |
+
return
|
| 39 |
+
|
| 40 |
+
# 初始化统一存储适配器
|
| 41 |
+
self._storage_adapter = await get_storage_adapter()
|
| 42 |
+
self._initialized = True
|
| 43 |
+
|
| 44 |
+
async def close(self):
|
| 45 |
+
"""清理资源"""
|
| 46 |
+
log.debug("Closing credential manager...")
|
| 47 |
+
self._initialized = False
|
| 48 |
+
log.debug("Credential manager closed")
|
| 49 |
+
|
| 50 |
+
async def get_valid_credential(
|
| 51 |
+
self, mode: str = "geminicli", model_key: Optional[str] = None
|
| 52 |
+
) -> Optional[Tuple[str, Dict[str, Any]]]:
|
| 53 |
+
"""
|
| 54 |
+
获取有效的凭证 - 随机负载均衡版
|
| 55 |
+
每次随机选择一个可用的凭证(未禁用、未冷却)
|
| 56 |
+
如果刷新失败会自动禁用失效凭证并重试获取下一个可用凭证
|
| 57 |
+
|
| 58 |
+
Args:
|
| 59 |
+
mode: 凭证模式 ("geminicli" 或 "antigravity")
|
| 60 |
+
model_key: 模型键,用于模型级冷却检查
|
| 61 |
+
- antigravity: 模型名称(如 "gemini-2.0-flash-exp")
|
| 62 |
+
- gcli: "pro" 或 "flash"
|
| 63 |
+
"""
|
| 64 |
+
await self._ensure_initialized()
|
| 65 |
+
|
| 66 |
+
# 最多重试3次
|
| 67 |
+
max_retries = 3
|
| 68 |
+
for attempt in range(max_retries):
|
| 69 |
+
result = await self._storage_adapter._backend.get_next_available_credential(
|
| 70 |
+
mode=mode, model_key=model_key
|
| 71 |
+
)
|
| 72 |
+
|
| 73 |
+
# 如果没有可用凭证,直接返回None
|
| 74 |
+
if not result:
|
| 75 |
+
if attempt == 0:
|
| 76 |
+
log.warning(f"没有可用凭证 (mode={mode}, model_key={model_key})")
|
| 77 |
+
return None
|
| 78 |
+
|
| 79 |
+
filename, credential_data = result
|
| 80 |
+
|
| 81 |
+
# Token 刷新检查
|
| 82 |
+
if await self._should_refresh_token(credential_data):
|
| 83 |
+
log.debug(f"Token需要刷新 - 文件: {filename} (mode={mode})")
|
| 84 |
+
refreshed_data = await self._refresh_token(credential_data, filename, mode=mode)
|
| 85 |
+
if refreshed_data:
|
| 86 |
+
# 刷新成功,返回凭证
|
| 87 |
+
credential_data = refreshed_data
|
| 88 |
+
log.debug(f"Token刷新成功: {filename} (mode={mode})")
|
| 89 |
+
return filename, credential_data
|
| 90 |
+
else:
|
| 91 |
+
# 刷新失败(_refresh_token内部已自动禁用失效凭证)
|
| 92 |
+
log.warning(f"Token刷新失败,尝试获取下一个凭证: {filename} (mode={mode}, attempt={attempt+1}/{max_retries})")
|
| 93 |
+
# 继续循环,尝试获取下一个可用凭证
|
| 94 |
+
continue
|
| 95 |
+
else:
|
| 96 |
+
# Token有效,直接返回
|
| 97 |
+
return filename, credential_data
|
| 98 |
+
|
| 99 |
+
# 重试次数用尽
|
| 100 |
+
log.error(f"重试{max_retries}次后仍无可用凭证 (mode={mode}, model_key={model_key})")
|
| 101 |
+
return None
|
| 102 |
+
|
| 103 |
+
async def add_credential(self, credential_name: str, credential_data: Dict[str, Any]):
|
| 104 |
+
"""
|
| 105 |
+
新增或更新一个凭证
|
| 106 |
+
存储层会自动处理轮换顺序
|
| 107 |
+
"""
|
| 108 |
+
await self._ensure_initialized()
|
| 109 |
+
async with self._operation_lock:
|
| 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 |
+
async with self._operation_lock:
|
| 120 |
+
await self._storage_adapter.store_credential(credential_name, credential_data, mode="antigravity")
|
| 121 |
+
log.info(f"Antigravity credential added/updated: {credential_name}")
|
| 122 |
+
|
| 123 |
+
async def remove_credential(self, credential_name: str, mode: str = "geminicli") -> bool:
|
| 124 |
+
"""删除一个凭证"""
|
| 125 |
+
await self._ensure_initialized()
|
| 126 |
+
async with self._operation_lock:
|
| 127 |
+
try:
|
| 128 |
+
await self._storage_adapter.delete_credential(credential_name, mode=mode)
|
| 129 |
+
log.info(f"Credential removed: {credential_name} (mode={mode})")
|
| 130 |
+
return True
|
| 131 |
+
except Exception as e:
|
| 132 |
+
log.error(f"Error removing credential {credential_name}: {e}")
|
| 133 |
+
return False
|
| 134 |
+
|
| 135 |
+
async def update_credential_state(self, credential_name: str, state_updates: Dict[str, Any], mode: str = "geminicli"):
|
| 136 |
+
"""更新凭证状态"""
|
| 137 |
+
log.debug(f"[CredMgr] update_credential_state 开始: credential_name={credential_name}, state_updates={state_updates}, mode={mode}")
|
| 138 |
+
log.debug(f"[CredMgr] 调用 _ensure_initialized...")
|
| 139 |
+
await self._ensure_initialized()
|
| 140 |
+
log.debug(f"[CredMgr] _ensure_initialized 完成")
|
| 141 |
+
try:
|
| 142 |
+
log.debug(f"[CredMgr] 调用 storage_adapter.update_credential_state...")
|
| 143 |
+
success = await self._storage_adapter.update_credential_state(
|
| 144 |
+
credential_name, state_updates, mode=mode
|
| 145 |
+
)
|
| 146 |
+
log.debug(f"[CredMgr] storage_adapter.update_credential_state 返回: {success}")
|
| 147 |
+
if success:
|
| 148 |
+
log.debug(f"Updated credential state: {credential_name} (mode={mode})")
|
| 149 |
+
else:
|
| 150 |
+
log.warning(f"Failed to update credential state: {credential_name} (mode={mode})")
|
| 151 |
+
return success
|
| 152 |
+
except Exception as e:
|
| 153 |
+
log.error(f"Error updating credential state {credential_name}: {e}", exc_info=True)
|
| 154 |
+
return False
|
| 155 |
+
|
| 156 |
+
async def set_cred_disabled(self, credential_name: str, disabled: bool, mode: str = "geminicli"):
|
| 157 |
+
"""设置凭证的启用/禁用状态"""
|
| 158 |
+
try:
|
| 159 |
+
log.info(f"[CredMgr] set_cred_disabled 开始: credential_name={credential_name}, disabled={disabled}, mode={mode}")
|
| 160 |
+
success = await self.update_credential_state(
|
| 161 |
+
credential_name, {"disabled": disabled}, mode=mode
|
| 162 |
+
)
|
| 163 |
+
log.info(f"[CredMgr] update_credential_state 返回: success={success}")
|
| 164 |
+
if success:
|
| 165 |
+
action = "disabled" if disabled else "enabled"
|
| 166 |
+
log.info(f"Credential {action}: {credential_name} (mode={mode})")
|
| 167 |
+
else:
|
| 168 |
+
log.warning(f"[CredMgr] 设置禁用状态失败: credential_name={credential_name}, disabled={disabled}")
|
| 169 |
+
return success
|
| 170 |
+
except Exception as e:
|
| 171 |
+
log.error(f"Error setting credential disabled state {credential_name}: {e}")
|
| 172 |
+
return False
|
| 173 |
+
|
| 174 |
+
async def get_creds_status(self) -> Dict[str, Dict[str, Any]]:
|
| 175 |
+
"""获取所有凭证的状态"""
|
| 176 |
+
await self._ensure_initialized()
|
| 177 |
+
try:
|
| 178 |
+
return await self._storage_adapter.get_all_credential_states()
|
| 179 |
+
except Exception as e:
|
| 180 |
+
log.error(f"Error getting credential statuses: {e}")
|
| 181 |
+
return {}
|
| 182 |
+
|
| 183 |
+
async def get_creds_summary(self) -> List[Dict[str, Any]]:
|
| 184 |
+
"""
|
| 185 |
+
获取所有凭证的摘要信息(轻量级,不包含完整凭证数据)
|
| 186 |
+
优先使用后端的高性能查询
|
| 187 |
+
"""
|
| 188 |
+
await self._ensure_initialized()
|
| 189 |
+
try:
|
| 190 |
+
# 如果后端支持高性能摘要查询,直接使用
|
| 191 |
+
if hasattr(self._storage_adapter._backend, 'get_credentials_summary'):
|
| 192 |
+
return await self._storage_adapter._backend.get_credentials_summary()
|
| 193 |
+
|
| 194 |
+
# 否则回退到传统方式
|
| 195 |
+
all_states = await self._storage_adapter.get_all_credential_states()
|
| 196 |
+
summaries = []
|
| 197 |
+
|
| 198 |
+
import time
|
| 199 |
+
current_time = time.time()
|
| 200 |
+
|
| 201 |
+
for filename, state in all_states.items():
|
| 202 |
+
summaries.append({
|
| 203 |
+
"filename": filename,
|
| 204 |
+
"disabled": state.get("disabled", False),
|
| 205 |
+
"error_codes": state.get("error_codes", []),
|
| 206 |
+
"last_success": state.get("last_success", current_time),
|
| 207 |
+
"user_email": state.get("user_email"),
|
| 208 |
+
"model_cooldowns": state.get("model_cooldowns", {}),
|
| 209 |
+
})
|
| 210 |
+
|
| 211 |
+
return summaries
|
| 212 |
+
|
| 213 |
+
except Exception as e:
|
| 214 |
+
log.error(f"Error getting credentials summary: {e}")
|
| 215 |
+
return []
|
| 216 |
+
|
| 217 |
+
async def get_or_fetch_user_email(self, credential_name: str, mode: str = "geminicli") -> Optional[str]:
|
| 218 |
+
"""获取或获取用户邮箱地址"""
|
| 219 |
+
try:
|
| 220 |
+
# 确保已初始化
|
| 221 |
+
await self._ensure_initialized()
|
| 222 |
+
|
| 223 |
+
# 从状态中获取缓存的邮箱
|
| 224 |
+
state = await self._storage_adapter.get_credential_state(credential_name, mode=mode)
|
| 225 |
+
cached_email = state.get("user_email") if state else None
|
| 226 |
+
|
| 227 |
+
if cached_email:
|
| 228 |
+
return cached_email
|
| 229 |
+
|
| 230 |
+
# 如果没有缓存,从凭证数据获取
|
| 231 |
+
credential_data = await self._storage_adapter.get_credential(credential_name, mode=mode)
|
| 232 |
+
if not credential_data:
|
| 233 |
+
return None
|
| 234 |
+
|
| 235 |
+
# 创建凭证对象并自动刷新 token
|
| 236 |
+
from .google_oauth_api import Credentials, get_user_email
|
| 237 |
+
|
| 238 |
+
credentials = Credentials.from_dict(credential_data)
|
| 239 |
+
if not credentials:
|
| 240 |
+
return None
|
| 241 |
+
|
| 242 |
+
# 自动刷新 token(如果需要)
|
| 243 |
+
token_refreshed = await credentials.refresh_if_needed()
|
| 244 |
+
|
| 245 |
+
# 如果 token 被刷新了,更新存储
|
| 246 |
+
if token_refreshed:
|
| 247 |
+
log.info(f"Token已自动刷新: {credential_name} (mode={mode})")
|
| 248 |
+
updated_data = credentials.to_dict()
|
| 249 |
+
await self._storage_adapter.store_credential(credential_name, updated_data, mode=mode)
|
| 250 |
+
|
| 251 |
+
# 获取邮箱
|
| 252 |
+
email = await get_user_email(credentials)
|
| 253 |
+
|
| 254 |
+
if email:
|
| 255 |
+
# 缓存邮箱地址
|
| 256 |
+
await self._storage_adapter.update_credential_state(
|
| 257 |
+
credential_name, {"user_email": email}, mode=mode
|
| 258 |
+
)
|
| 259 |
+
return email
|
| 260 |
+
|
| 261 |
+
return None
|
| 262 |
+
|
| 263 |
+
except Exception as e:
|
| 264 |
+
log.error(f"Error fetching user email for {credential_name}: {e}")
|
| 265 |
+
return None
|
| 266 |
+
|
| 267 |
+
async def record_api_call_result(
|
| 268 |
+
self,
|
| 269 |
+
credential_name: str,
|
| 270 |
+
success: bool,
|
| 271 |
+
error_code: Optional[int] = None,
|
| 272 |
+
cooldown_until: Optional[float] = None,
|
| 273 |
+
mode: str = "geminicli",
|
| 274 |
+
model_key: Optional[str] = None
|
| 275 |
+
):
|
| 276 |
+
"""
|
| 277 |
+
记录API调用结果
|
| 278 |
+
|
| 279 |
+
Args:
|
| 280 |
+
credential_name: 凭证名称
|
| 281 |
+
success: 是否成功
|
| 282 |
+
error_code: 错误码(如果失败)
|
| 283 |
+
cooldown_until: 冷却截止时间戳(Unix时间戳,针对429 QUOTA_EXHAUSTED)
|
| 284 |
+
mode: 凭证模式 ("geminicli" 或 "antigravity")
|
| 285 |
+
model_key: 模型键(用于设置模型级冷却)
|
| 286 |
+
"""
|
| 287 |
+
await self._ensure_initialized()
|
| 288 |
+
try:
|
| 289 |
+
state_updates = {}
|
| 290 |
+
|
| 291 |
+
if success:
|
| 292 |
+
state_updates["last_success"] = time.time()
|
| 293 |
+
# 清除错误码
|
| 294 |
+
state_updates["error_codes"] = []
|
| 295 |
+
|
| 296 |
+
# 如果提供了 model_key,清除该模型的冷却
|
| 297 |
+
if model_key:
|
| 298 |
+
if hasattr(self._storage_adapter._backend, 'set_model_cooldown'):
|
| 299 |
+
await self._storage_adapter._backend.set_model_cooldown(
|
| 300 |
+
credential_name, model_key, None, mode=mode
|
| 301 |
+
)
|
| 302 |
+
|
| 303 |
+
elif error_code:
|
| 304 |
+
# 记录错误码
|
| 305 |
+
current_state = await self._storage_adapter.get_credential_state(credential_name, mode=mode)
|
| 306 |
+
error_codes = current_state.get("error_codes", [])
|
| 307 |
+
|
| 308 |
+
if error_code not in error_codes:
|
| 309 |
+
error_codes.append(error_code)
|
| 310 |
+
# 限制错误码列表长度
|
| 311 |
+
if len(error_codes) > 10:
|
| 312 |
+
error_codes = error_codes[-10:]
|
| 313 |
+
|
| 314 |
+
state_updates["error_codes"] = error_codes
|
| 315 |
+
|
| 316 |
+
# 如果提供了冷却时间和模型键,设置模型级冷却
|
| 317 |
+
if cooldown_until is not None and model_key:
|
| 318 |
+
if hasattr(self._storage_adapter._backend, 'set_model_cooldown'):
|
| 319 |
+
await self._storage_adapter._backend.set_model_cooldown(
|
| 320 |
+
credential_name, model_key, cooldown_until, mode=mode
|
| 321 |
+
)
|
| 322 |
+
log.info(
|
| 323 |
+
f"设置模型级冷却: {credential_name}, model_key={model_key}, "
|
| 324 |
+
f"冷却至: {datetime.fromtimestamp(cooldown_until, timezone.utc).isoformat()}"
|
| 325 |
+
)
|
| 326 |
+
|
| 327 |
+
if state_updates:
|
| 328 |
+
await self.update_credential_state(credential_name, state_updates, mode=mode)
|
| 329 |
+
|
| 330 |
+
except Exception as e:
|
| 331 |
+
log.error(f"Error recording API call result for {credential_name}: {e}")
|
| 332 |
+
|
| 333 |
+
async def _should_refresh_token(self, credential_data: Dict[str, Any]) -> bool:
|
| 334 |
+
"""检查token是否需要刷新"""
|
| 335 |
+
try:
|
| 336 |
+
# 如果没有access_token或过期时间,需要刷新
|
| 337 |
+
if not credential_data.get("access_token") and not credential_data.get("token"):
|
| 338 |
+
log.debug("没有access_token,需要刷新")
|
| 339 |
+
return True
|
| 340 |
+
|
| 341 |
+
expiry_str = credential_data.get("expiry")
|
| 342 |
+
if not expiry_str:
|
| 343 |
+
log.debug("没有过期时间,需要刷新")
|
| 344 |
+
return True
|
| 345 |
+
|
| 346 |
+
# 解析过期时间
|
| 347 |
+
try:
|
| 348 |
+
if isinstance(expiry_str, str):
|
| 349 |
+
if "+" in expiry_str:
|
| 350 |
+
file_expiry = datetime.fromisoformat(expiry_str)
|
| 351 |
+
elif expiry_str.endswith("Z"):
|
| 352 |
+
file_expiry = datetime.fromisoformat(expiry_str.replace("Z", "+00:00"))
|
| 353 |
+
else:
|
| 354 |
+
file_expiry = datetime.fromisoformat(expiry_str)
|
| 355 |
+
else:
|
| 356 |
+
log.debug("过期时间格式无效,需要刷新")
|
| 357 |
+
return True
|
| 358 |
+
|
| 359 |
+
# 确保时区信息
|
| 360 |
+
if file_expiry.tzinfo is None:
|
| 361 |
+
file_expiry = file_expiry.replace(tzinfo=timezone.utc)
|
| 362 |
+
|
| 363 |
+
# 检查是否还有至少5分钟有效期
|
| 364 |
+
now = datetime.now(timezone.utc)
|
| 365 |
+
time_left = (file_expiry - now).total_seconds()
|
| 366 |
+
|
| 367 |
+
log.debug(
|
| 368 |
+
f"Token时间检查: "
|
| 369 |
+
f"当前UTC时间={now.isoformat()}, "
|
| 370 |
+
f"过期时间={file_expiry.isoformat()}, "
|
| 371 |
+
f"剩余时间={int(time_left/60)}分{int(time_left%60)}秒"
|
| 372 |
+
)
|
| 373 |
+
|
| 374 |
+
if time_left > 300: # 5分钟缓冲
|
| 375 |
+
return False
|
| 376 |
+
else:
|
| 377 |
+
log.debug(f"Token即将过期(剩余{int(time_left/60)}分钟),需要刷新")
|
| 378 |
+
return True
|
| 379 |
+
|
| 380 |
+
except Exception as e:
|
| 381 |
+
log.warning(f"解析过期时间失败: {e},需要刷新")
|
| 382 |
+
return True
|
| 383 |
+
|
| 384 |
+
except Exception as e:
|
| 385 |
+
log.error(f"检查token过期时出错: {e}")
|
| 386 |
+
return True
|
| 387 |
+
|
| 388 |
+
async def _refresh_token(
|
| 389 |
+
self, credential_data: Dict[str, Any], filename: str, mode: str = "geminicli"
|
| 390 |
+
) -> Optional[Dict[str, Any]]:
|
| 391 |
+
"""刷新token并更新存储"""
|
| 392 |
+
await self._ensure_initialized()
|
| 393 |
+
try:
|
| 394 |
+
# 创建Credentials对象
|
| 395 |
+
creds = Credentials.from_dict(credential_data)
|
| 396 |
+
|
| 397 |
+
# 检查是否可以刷新
|
| 398 |
+
if not creds.refresh_token:
|
| 399 |
+
log.error(f"没有refresh_token,无法刷新: {filename} (mode={mode})")
|
| 400 |
+
# 自动禁用没有refresh_token的凭证
|
| 401 |
+
try:
|
| 402 |
+
await self.update_credential_state(filename, {"disabled": True}, mode=mode)
|
| 403 |
+
log.warning(f"凭证已自动禁用(缺少refresh_token): {filename}")
|
| 404 |
+
except Exception as e:
|
| 405 |
+
log.error(f"禁用凭证失败 {filename}: {e}")
|
| 406 |
+
return None
|
| 407 |
+
|
| 408 |
+
# 刷新token
|
| 409 |
+
log.debug(f"正在刷新token: {filename} (mode={mode})")
|
| 410 |
+
await creds.refresh()
|
| 411 |
+
|
| 412 |
+
# 更新凭证数据
|
| 413 |
+
if creds.access_token:
|
| 414 |
+
credential_data["access_token"] = creds.access_token
|
| 415 |
+
# 保持兼容性
|
| 416 |
+
credential_data["token"] = creds.access_token
|
| 417 |
+
|
| 418 |
+
if creds.expires_at:
|
| 419 |
+
credential_data["expiry"] = creds.expires_at.isoformat()
|
| 420 |
+
|
| 421 |
+
# 保存到存储
|
| 422 |
+
await self._storage_adapter.store_credential(filename, credential_data, mode=mode)
|
| 423 |
+
log.info(f"Token刷新成功并已保存: {filename} (mode={mode})")
|
| 424 |
+
|
| 425 |
+
return credential_data
|
| 426 |
+
|
| 427 |
+
except Exception as e:
|
| 428 |
+
error_msg = str(e)
|
| 429 |
+
log.error(f"Token刷新失败 {filename} (mode={mode}): {error_msg}")
|
| 430 |
+
|
| 431 |
+
# 尝试提取HTTP状态码(TokenError可能携带status_code属性)
|
| 432 |
+
status_code = None
|
| 433 |
+
if hasattr(e, 'status_code'):
|
| 434 |
+
status_code = e.status_code
|
| 435 |
+
|
| 436 |
+
# 检查是否是凭证永久失效的错误(只有明确的400/403等才判定为永久失效)
|
| 437 |
+
is_permanent_failure = self._is_permanent_refresh_failure(error_msg, status_code)
|
| 438 |
+
|
| 439 |
+
if is_permanent_failure:
|
| 440 |
+
log.warning(f"检测到凭证永久失效 (HTTP {status_code}): {filename}")
|
| 441 |
+
# 记录失效状态
|
| 442 |
+
if status_code:
|
| 443 |
+
await self.record_api_call_result(filename, False, status_code, mode=mode)
|
| 444 |
+
else:
|
| 445 |
+
await self.record_api_call_result(filename, False, 400, mode=mode)
|
| 446 |
+
|
| 447 |
+
# 禁用失效凭证
|
| 448 |
+
try:
|
| 449 |
+
# 直接禁用该凭证(随机选择机制会自动跳过它)
|
| 450 |
+
disabled_ok = await self.update_credential_state(filename, {"disabled": True}, mode=mode)
|
| 451 |
+
if disabled_ok:
|
| 452 |
+
log.warning(f"永久失效凭证已禁用: {filename}")
|
| 453 |
+
else:
|
| 454 |
+
log.warning("永久失效凭证禁用失败,将由上层逻辑继续处理")
|
| 455 |
+
except Exception as e2:
|
| 456 |
+
log.error(f"禁用永久失效凭证时出错 {filename}: {e2}")
|
| 457 |
+
else:
|
| 458 |
+
# 网络错误或其他临时性错误,不封禁凭证
|
| 459 |
+
log.warning(f"Token刷新失败但非永久性错误 (HTTP {status_code}),不封禁凭证: {filename}")
|
| 460 |
+
|
| 461 |
+
return None
|
| 462 |
+
|
| 463 |
+
def _is_permanent_refresh_failure(self, error_msg: str, status_code: Optional[int] = None) -> bool:
|
| 464 |
+
"""
|
| 465 |
+
判断是否是凭证永久失效的错误
|
| 466 |
+
|
| 467 |
+
Args:
|
| 468 |
+
error_msg: 错误信息
|
| 469 |
+
status_code: HTTP状态码(如果有)
|
| 470 |
+
|
| 471 |
+
Returns:
|
| 472 |
+
True表示凭证永久���效应封禁,False表示临时错误不应封禁
|
| 473 |
+
"""
|
| 474 |
+
# 优先使用HTTP状态码判断
|
| 475 |
+
if status_code is not None:
|
| 476 |
+
# 400/401/403 明确表示凭证有问题,应该封禁
|
| 477 |
+
if status_code in [400, 401, 403]:
|
| 478 |
+
log.debug(f"检测到客户端错误状态码 {status_code},判定为永久失效")
|
| 479 |
+
return True
|
| 480 |
+
# 500/502/503/504 是服务器错误,不应封禁凭证
|
| 481 |
+
elif status_code in [500, 502, 503, 504]:
|
| 482 |
+
log.debug(f"检测到服务器错误状态码 {status_code},不应封禁凭证")
|
| 483 |
+
return False
|
| 484 |
+
# 429 (限流) 不应封禁凭证
|
| 485 |
+
elif status_code == 429:
|
| 486 |
+
log.debug("检测到限流错误 429,不应封禁凭证")
|
| 487 |
+
return False
|
| 488 |
+
|
| 489 |
+
# 如果没有状态码,回退到错误信息匹配(谨慎判断)
|
| 490 |
+
# 只有明确的凭证失效错误才判定为永久失效
|
| 491 |
+
permanent_error_patterns = [
|
| 492 |
+
"invalid_grant",
|
| 493 |
+
"refresh_token_expired",
|
| 494 |
+
"invalid_refresh_token",
|
| 495 |
+
"unauthorized_client",
|
| 496 |
+
"access_denied",
|
| 497 |
+
]
|
| 498 |
+
|
| 499 |
+
error_msg_lower = error_msg.lower()
|
| 500 |
+
for pattern in permanent_error_patterns:
|
| 501 |
+
if pattern.lower() in error_msg_lower:
|
| 502 |
+
log.debug(f"错误信息匹配到永久失效模式: {pattern}")
|
| 503 |
+
return True
|
| 504 |
+
|
| 505 |
+
# 默认认为是临时错误(如网络问题),不应封禁凭证
|
| 506 |
+
log.debug("未匹配到明确的永久失效模式,判定为临时错误")
|
| 507 |
+
return False
|
| 508 |
+
|
| 509 |
+
# 全局实例管理(保持兼容性)
|
| 510 |
+
_credential_manager: Optional[CredentialManager] = None
|
| 511 |
+
|
| 512 |
+
|
| 513 |
+
async def get_credential_manager() -> CredentialManager:
|
| 514 |
+
"""获取全局凭证管理器实例"""
|
| 515 |
+
global _credential_manager
|
| 516 |
+
|
| 517 |
+
if _credential_manager is None:
|
| 518 |
+
_credential_manager = CredentialManager()
|
| 519 |
+
await _credential_manager.initialize()
|
| 520 |
+
|
| 521 |
+
return _credential_manager
|
src/google_oauth_api.py
ADDED
|
@@ -0,0 +1,781 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 .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(
|
| 535 |
+
access_token: str,
|
| 536 |
+
user_agent: str,
|
| 537 |
+
api_base_url: str
|
| 538 |
+
) -> Optional[str]:
|
| 539 |
+
"""
|
| 540 |
+
从 API 获取 project_id,如果 loadCodeAssist 失败则回退到 onboardUser
|
| 541 |
+
|
| 542 |
+
Args:
|
| 543 |
+
access_token: Google OAuth access token
|
| 544 |
+
user_agent: User-Agent header
|
| 545 |
+
api_base_url: API base URL (e.g., antigravity or code assist endpoint)
|
| 546 |
+
|
| 547 |
+
Returns:
|
| 548 |
+
project_id 字符串,如果获取失败返回 None
|
| 549 |
+
"""
|
| 550 |
+
headers = {
|
| 551 |
+
'User-Agent': user_agent,
|
| 552 |
+
'Authorization': f'Bearer {access_token}',
|
| 553 |
+
'Content-Type': 'application/json',
|
| 554 |
+
'Accept-Encoding': 'gzip'
|
| 555 |
+
}
|
| 556 |
+
|
| 557 |
+
# 步骤 1: 尝试 loadCodeAssist
|
| 558 |
+
try:
|
| 559 |
+
project_id = await _try_load_code_assist(api_base_url, headers)
|
| 560 |
+
if project_id:
|
| 561 |
+
return project_id
|
| 562 |
+
|
| 563 |
+
log.warning("[fetch_project_id] loadCodeAssist did not return project_id, falling back to onboardUser")
|
| 564 |
+
|
| 565 |
+
except Exception as e:
|
| 566 |
+
log.warning(f"[fetch_project_id] loadCodeAssist failed: {type(e).__name__}: {e}")
|
| 567 |
+
log.warning("[fetch_project_id] Falling back to onboardUser")
|
| 568 |
+
|
| 569 |
+
# 步骤 2: 回退到 onboardUser
|
| 570 |
+
try:
|
| 571 |
+
project_id = await _try_onboard_user(api_base_url, headers)
|
| 572 |
+
if project_id:
|
| 573 |
+
return project_id
|
| 574 |
+
|
| 575 |
+
log.error("[fetch_project_id] Failed to get project_id from both loadCodeAssist and onboardUser")
|
| 576 |
+
return None
|
| 577 |
+
|
| 578 |
+
except Exception as e:
|
| 579 |
+
log.error(f"[fetch_project_id] onboardUser failed: {type(e).__name__}: {e}")
|
| 580 |
+
import traceback
|
| 581 |
+
log.debug(f"[fetch_project_id] Traceback: {traceback.format_exc()}")
|
| 582 |
+
return None
|
| 583 |
+
|
| 584 |
+
|
| 585 |
+
async def _try_load_code_assist(
|
| 586 |
+
api_base_url: str,
|
| 587 |
+
headers: dict
|
| 588 |
+
) -> Optional[str]:
|
| 589 |
+
"""
|
| 590 |
+
尝试通过 loadCodeAssist 获取 project_id
|
| 591 |
+
|
| 592 |
+
Returns:
|
| 593 |
+
project_id 或 None
|
| 594 |
+
"""
|
| 595 |
+
request_url = f"{api_base_url.rstrip('/')}/v1internal:loadCodeAssist"
|
| 596 |
+
request_body = {
|
| 597 |
+
"metadata": {
|
| 598 |
+
"ideType": "ANTIGRAVITY",
|
| 599 |
+
"platform": "PLATFORM_UNSPECIFIED",
|
| 600 |
+
"pluginType": "GEMINI"
|
| 601 |
+
}
|
| 602 |
+
}
|
| 603 |
+
|
| 604 |
+
log.debug(f"[loadCodeAssist] Fetching project_id from: {request_url}")
|
| 605 |
+
log.debug(f"[loadCodeAssist] Request body: {request_body}")
|
| 606 |
+
|
| 607 |
+
response = await post_async(
|
| 608 |
+
request_url,
|
| 609 |
+
json=request_body,
|
| 610 |
+
headers=headers,
|
| 611 |
+
timeout=30.0,
|
| 612 |
+
)
|
| 613 |
+
|
| 614 |
+
log.debug(f"[loadCodeAssist] Response status: {response.status_code}")
|
| 615 |
+
|
| 616 |
+
if response.status_code == 200:
|
| 617 |
+
response_text = response.text
|
| 618 |
+
log.debug(f"[loadCodeAssist] Response body: {response_text}")
|
| 619 |
+
|
| 620 |
+
data = response.json()
|
| 621 |
+
log.debug(f"[loadCodeAssist] Response JSON keys: {list(data.keys())}")
|
| 622 |
+
|
| 623 |
+
# 检查是否有 currentTier(表示用户已激活)
|
| 624 |
+
current_tier = data.get("currentTier")
|
| 625 |
+
if current_tier:
|
| 626 |
+
log.info("[loadCodeAssist] User is already activated")
|
| 627 |
+
|
| 628 |
+
# 使用服务器返回的 project_id
|
| 629 |
+
project_id = data.get("cloudaicompanionProject")
|
| 630 |
+
if project_id:
|
| 631 |
+
log.info(f"[loadCodeAssist] Successfully fetched project_id: {project_id}")
|
| 632 |
+
return project_id
|
| 633 |
+
|
| 634 |
+
log.warning("[loadCodeAssist] No project_id in response")
|
| 635 |
+
return None
|
| 636 |
+
else:
|
| 637 |
+
log.info("[loadCodeAssist] User not activated yet (no currentTier)")
|
| 638 |
+
return None
|
| 639 |
+
else:
|
| 640 |
+
log.warning(f"[loadCodeAssist] Failed: HTTP {response.status_code}")
|
| 641 |
+
log.warning(f"[loadCodeAssist] Response body: {response.text[:500]}")
|
| 642 |
+
raise Exception(f"HTTP {response.status_code}: {response.text[:200]}")
|
| 643 |
+
|
| 644 |
+
|
| 645 |
+
async def _try_onboard_user(
|
| 646 |
+
api_base_url: str,
|
| 647 |
+
headers: dict
|
| 648 |
+
) -> Optional[str]:
|
| 649 |
+
"""
|
| 650 |
+
尝试通过 onboardUser 获取 project_id(长时间运行操作,需要轮询)
|
| 651 |
+
|
| 652 |
+
Returns:
|
| 653 |
+
project_id 或 None
|
| 654 |
+
"""
|
| 655 |
+
request_url = f"{api_base_url.rstrip('/')}/v1internal:onboardUser"
|
| 656 |
+
|
| 657 |
+
# 首先需要获取用户的 tier 信息
|
| 658 |
+
tier_id = await _get_onboard_tier(api_base_url, headers)
|
| 659 |
+
if not tier_id:
|
| 660 |
+
log.error("[onboardUser] Failed to determine user tier")
|
| 661 |
+
return None
|
| 662 |
+
|
| 663 |
+
log.info(f"[onboardUser] User tier: {tier_id}")
|
| 664 |
+
|
| 665 |
+
# 构造 onboardUser 请求
|
| 666 |
+
# 注意:FREE tier 不应该包含 cloudaicompanionProject
|
| 667 |
+
request_body = {
|
| 668 |
+
"tierId": tier_id,
|
| 669 |
+
"metadata": {
|
| 670 |
+
"ideType": "ANTIGRAVITY",
|
| 671 |
+
"platform": "PLATFORM_UNSPECIFIED",
|
| 672 |
+
"pluginType": "GEMINI"
|
| 673 |
+
}
|
| 674 |
+
}
|
| 675 |
+
|
| 676 |
+
log.debug(f"[onboardUser] Request URL: {request_url}")
|
| 677 |
+
log.debug(f"[onboardUser] Request body: {request_body}")
|
| 678 |
+
|
| 679 |
+
# onboardUser 是长时间运行操作,需要轮询
|
| 680 |
+
# 最多等待 10 秒(5 次 * 2 秒)
|
| 681 |
+
max_attempts = 5
|
| 682 |
+
attempt = 0
|
| 683 |
+
|
| 684 |
+
while attempt < max_attempts:
|
| 685 |
+
attempt += 1
|
| 686 |
+
log.debug(f"[onboardUser] Polling attempt {attempt}/{max_attempts}")
|
| 687 |
+
|
| 688 |
+
response = await post_async(
|
| 689 |
+
request_url,
|
| 690 |
+
json=request_body,
|
| 691 |
+
headers=headers,
|
| 692 |
+
timeout=30.0,
|
| 693 |
+
)
|
| 694 |
+
|
| 695 |
+
log.debug(f"[onboardUser] Response status: {response.status_code}")
|
| 696 |
+
|
| 697 |
+
if response.status_code == 200:
|
| 698 |
+
data = response.json()
|
| 699 |
+
log.debug(f"[onboardUser] Response data: {data}")
|
| 700 |
+
|
| 701 |
+
# 检查长时间运行操作是否完成
|
| 702 |
+
if data.get("done"):
|
| 703 |
+
log.info("[onboardUser] Operation completed")
|
| 704 |
+
|
| 705 |
+
# 从响应中提取 project_id
|
| 706 |
+
response_data = data.get("response", {})
|
| 707 |
+
project_obj = response_data.get("cloudaicompanionProject", {})
|
| 708 |
+
|
| 709 |
+
if isinstance(project_obj, dict):
|
| 710 |
+
project_id = project_obj.get("id")
|
| 711 |
+
elif isinstance(project_obj, str):
|
| 712 |
+
project_id = project_obj
|
| 713 |
+
else:
|
| 714 |
+
project_id = None
|
| 715 |
+
|
| 716 |
+
if project_id:
|
| 717 |
+
log.info(f"[onboardUser] Successfully fetched project_id: {project_id}")
|
| 718 |
+
return project_id
|
| 719 |
+
else:
|
| 720 |
+
log.warning("[onboardUser] Operation completed but no project_id in response")
|
| 721 |
+
return None
|
| 722 |
+
else:
|
| 723 |
+
log.debug("[onboardUser] Operation still in progress, waiting 2 seconds...")
|
| 724 |
+
await asyncio.sleep(2)
|
| 725 |
+
else:
|
| 726 |
+
log.warning(f"[onboardUser] Failed: HTTP {response.status_code}")
|
| 727 |
+
log.warning(f"[onboardUser] Response body: {response.text[:500]}")
|
| 728 |
+
raise Exception(f"HTTP {response.status_code}: {response.text[:200]}")
|
| 729 |
+
|
| 730 |
+
log.error("[onboardUser] Timeout: Operation did not complete within 10 seconds")
|
| 731 |
+
return None
|
| 732 |
+
|
| 733 |
+
|
| 734 |
+
async def _get_onboard_tier(
|
| 735 |
+
api_base_url: str,
|
| 736 |
+
headers: dict
|
| 737 |
+
) -> Optional[str]:
|
| 738 |
+
"""
|
| 739 |
+
从 loadCodeAssist 响应中获取用户应该注册的 tier
|
| 740 |
+
|
| 741 |
+
Returns:
|
| 742 |
+
tier_id (如 "FREE", "STANDARD", "LEGACY") 或 None
|
| 743 |
+
"""
|
| 744 |
+
request_url = f"{api_base_url.rstrip('/')}/v1internal:loadCodeAssist"
|
| 745 |
+
request_body = {
|
| 746 |
+
"metadata": {
|
| 747 |
+
"ideType": "ANTIGRAVITY",
|
| 748 |
+
"platform": "PLATFORM_UNSPECIFIED",
|
| 749 |
+
"pluginType": "GEMINI"
|
| 750 |
+
}
|
| 751 |
+
}
|
| 752 |
+
|
| 753 |
+
log.debug(f"[_get_onboard_tier] Fetching tier info from: {request_url}")
|
| 754 |
+
|
| 755 |
+
response = await post_async(
|
| 756 |
+
request_url,
|
| 757 |
+
json=request_body,
|
| 758 |
+
headers=headers,
|
| 759 |
+
timeout=30.0,
|
| 760 |
+
)
|
| 761 |
+
|
| 762 |
+
if response.status_code == 200:
|
| 763 |
+
data = response.json()
|
| 764 |
+
log.debug(f"[_get_onboard_tier] Response data: {data}")
|
| 765 |
+
|
| 766 |
+
# 查找默认的 tier
|
| 767 |
+
allowed_tiers = data.get("allowedTiers", [])
|
| 768 |
+
for tier in allowed_tiers:
|
| 769 |
+
if tier.get("isDefault"):
|
| 770 |
+
tier_id = tier.get("id")
|
| 771 |
+
log.info(f"[_get_onboard_tier] Found default tier: {tier_id}")
|
| 772 |
+
return tier_id
|
| 773 |
+
|
| 774 |
+
# 如果没有默认 tier,使用 LEGACY 作为回退
|
| 775 |
+
log.warning("[_get_onboard_tier] No default tier found, using LEGACY")
|
| 776 |
+
return "LEGACY"
|
| 777 |
+
else:
|
| 778 |
+
log.error(f"[_get_onboard_tier] Failed to fetch tier info: HTTP {response.status_code}")
|
| 779 |
+
return None
|
| 780 |
+
|
| 781 |
+
|
src/httpx_client.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 = 30.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 |
+
async def stream_post_async(
|
| 86 |
+
url: str,
|
| 87 |
+
body: Dict[str, Any],
|
| 88 |
+
native: bool = False,
|
| 89 |
+
headers: Optional[Dict[str, str]] = None,
|
| 90 |
+
**kwargs,
|
| 91 |
+
):
|
| 92 |
+
"""流式异步POST请求"""
|
| 93 |
+
async with http_client.get_streaming_client(**kwargs) as client:
|
| 94 |
+
async with client.stream("POST", url, json=body, headers=headers) as r:
|
| 95 |
+
# 错误直接返回
|
| 96 |
+
if r.status_code != 200:
|
| 97 |
+
from fastapi import Response
|
| 98 |
+
yield Response(await r.aread(), r.status_code, dict(r.headers))
|
| 99 |
+
return
|
| 100 |
+
|
| 101 |
+
# 如果native=True,直接返回bytes流
|
| 102 |
+
if native:
|
| 103 |
+
async for chunk in r.aiter_bytes():
|
| 104 |
+
yield chunk
|
| 105 |
+
else:
|
| 106 |
+
# 通过aiter_lines转化成str流返回
|
| 107 |
+
async for line in r.aiter_lines():
|
| 108 |
+
yield line
|
src/models.py
ADDED
|
@@ -0,0 +1,376 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 的模型转字典方法
|
| 10 |
+
- v1: model.dict()
|
| 11 |
+
- v2: model.model_dump()
|
| 12 |
+
"""
|
| 13 |
+
if hasattr(model, 'model_dump'):
|
| 14 |
+
# Pydantic v2
|
| 15 |
+
return model.model_dump()
|
| 16 |
+
else:
|
| 17 |
+
# Pydantic v1
|
| 18 |
+
return model.dict()
|
| 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] = False
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
class GeminiContent(BaseModel):
|
| 133 |
+
role: str
|
| 134 |
+
parts: List[GeminiPart]
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
class GeminiSystemInstruction(BaseModel):
|
| 138 |
+
parts: List[GeminiPart]
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
class GeminiImageConfig(BaseModel):
|
| 142 |
+
"""图片生成配置"""
|
| 143 |
+
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"
|
| 144 |
+
image_size: Optional[str] = None # "1K", "2K", "4K"
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
class GeminiGenerationConfig(BaseModel):
|
| 148 |
+
temperature: Optional[float] = Field(None, ge=0.0, le=2.0)
|
| 149 |
+
topP: Optional[float] = Field(None, ge=0.0, le=1.0)
|
| 150 |
+
topK: Optional[int] = Field(None, ge=1)
|
| 151 |
+
maxOutputTokens: Optional[int] = Field(None, ge=1)
|
| 152 |
+
stopSequences: Optional[List[str]] = None
|
| 153 |
+
responseMimeType: Optional[str] = None
|
| 154 |
+
responseSchema: Optional[Dict[str, Any]] = None
|
| 155 |
+
candidateCount: Optional[int] = Field(None, ge=1, le=8)
|
| 156 |
+
seed: Optional[int] = None
|
| 157 |
+
frequencyPenalty: Optional[float] = Field(None, ge=-2.0, le=2.0)
|
| 158 |
+
presencePenalty: Optional[float] = Field(None, ge=-2.0, le=2.0)
|
| 159 |
+
thinkingConfig: Optional[Dict[str, Any]] = None
|
| 160 |
+
# 图片生成相关参数
|
| 161 |
+
response_modalities: Optional[List[str]] = None # ["TEXT", "IMAGE"]
|
| 162 |
+
image_config: Optional[GeminiImageConfig] = None
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
class GeminiSafetySetting(BaseModel):
|
| 166 |
+
category: str
|
| 167 |
+
threshold: str
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
class GeminiRequest(BaseModel):
|
| 171 |
+
contents: List[GeminiContent]
|
| 172 |
+
systemInstruction: Optional[GeminiSystemInstruction] = None
|
| 173 |
+
generationConfig: Optional[GeminiGenerationConfig] = None
|
| 174 |
+
safetySettings: Optional[List[GeminiSafetySetting]] = None
|
| 175 |
+
tools: Optional[List[Dict[str, Any]]] = None
|
| 176 |
+
toolConfig: Optional[Dict[str, Any]] = None
|
| 177 |
+
cachedContent: Optional[str] = None
|
| 178 |
+
|
| 179 |
+
class Config:
|
| 180 |
+
extra = "allow" # 允许透传未定义的字段
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
class GeminiCandidate(BaseModel):
|
| 184 |
+
content: GeminiContent
|
| 185 |
+
finishReason: Optional[str] = None
|
| 186 |
+
index: int = 0
|
| 187 |
+
safetyRatings: Optional[List[Dict[str, Any]]] = None
|
| 188 |
+
citationMetadata: Optional[Dict[str, Any]] = None
|
| 189 |
+
tokenCount: Optional[int] = None
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
class GeminiUsageMetadata(BaseModel):
|
| 193 |
+
promptTokenCount: Optional[int] = None
|
| 194 |
+
candidatesTokenCount: Optional[int] = None
|
| 195 |
+
totalTokenCount: Optional[int] = None
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
class GeminiResponse(BaseModel):
|
| 199 |
+
candidates: List[GeminiCandidate]
|
| 200 |
+
usageMetadata: Optional[GeminiUsageMetadata] = None
|
| 201 |
+
modelVersion: Optional[str] = None
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
# Claude Models
|
| 205 |
+
class ClaudeContentBlock(BaseModel):
|
| 206 |
+
type: str # "text", "image", "tool_use", "tool_result"
|
| 207 |
+
text: Optional[str] = None
|
| 208 |
+
source: Optional[Dict[str, Any]] = None # for image type
|
| 209 |
+
id: Optional[str] = None # for tool_use
|
| 210 |
+
name: Optional[str] = None # for tool_use
|
| 211 |
+
input: Optional[Dict[str, Any]] = None # for tool_use
|
| 212 |
+
tool_use_id: Optional[str] = None # for tool_result
|
| 213 |
+
content: Optional[Union[str, List[Dict[str, Any]]]] = None # for tool_result
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
class ClaudeMessage(BaseModel):
|
| 217 |
+
role: str # "user" or "assistant"
|
| 218 |
+
content: Union[str, List[ClaudeContentBlock]]
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
class ClaudeTool(BaseModel):
|
| 222 |
+
name: str
|
| 223 |
+
description: Optional[str] = None
|
| 224 |
+
input_schema: Dict[str, Any]
|
| 225 |
+
|
| 226 |
+
|
| 227 |
+
class ClaudeMetadata(BaseModel):
|
| 228 |
+
user_id: Optional[str] = None
|
| 229 |
+
|
| 230 |
+
|
| 231 |
+
class ClaudeRequest(BaseModel):
|
| 232 |
+
model: str
|
| 233 |
+
messages: List[ClaudeMessage]
|
| 234 |
+
max_tokens: int = Field(..., ge=1)
|
| 235 |
+
system: Optional[Union[str, List[Dict[str, Any]]]] = None
|
| 236 |
+
temperature: Optional[float] = Field(None, ge=0.0, le=1.0)
|
| 237 |
+
top_p: Optional[float] = Field(None, ge=0.0, le=1.0)
|
| 238 |
+
top_k: Optional[int] = Field(None, ge=1)
|
| 239 |
+
stop_sequences: Optional[List[str]] = None
|
| 240 |
+
stream: bool = False
|
| 241 |
+
metadata: Optional[ClaudeMetadata] = None
|
| 242 |
+
tools: Optional[List[ClaudeTool]] = None
|
| 243 |
+
tool_choice: Optional[Union[str, Dict[str, Any]]] = None
|
| 244 |
+
|
| 245 |
+
class Config:
|
| 246 |
+
extra = "allow"
|
| 247 |
+
|
| 248 |
+
|
| 249 |
+
class ClaudeUsage(BaseModel):
|
| 250 |
+
input_tokens: int
|
| 251 |
+
output_tokens: int
|
| 252 |
+
|
| 253 |
+
|
| 254 |
+
class ClaudeResponse(BaseModel):
|
| 255 |
+
id: str
|
| 256 |
+
type: str = "message"
|
| 257 |
+
role: str = "assistant"
|
| 258 |
+
content: List[ClaudeContentBlock]
|
| 259 |
+
model: str
|
| 260 |
+
stop_reason: Optional[str] = None
|
| 261 |
+
stop_sequence: Optional[str] = None
|
| 262 |
+
usage: ClaudeUsage
|
| 263 |
+
|
| 264 |
+
|
| 265 |
+
class ClaudeStreamEvent(BaseModel):
|
| 266 |
+
type: str # "message_start", "content_block_start", "content_block_delta", "content_block_stop", "message_delta", "message_stop"
|
| 267 |
+
message: Optional[ClaudeResponse] = None
|
| 268 |
+
index: Optional[int] = None
|
| 269 |
+
content_block: Optional[ClaudeContentBlock] = None
|
| 270 |
+
delta: Optional[Dict[str, Any]] = None
|
| 271 |
+
usage: Optional[ClaudeUsage] = None
|
| 272 |
+
|
| 273 |
+
class Config:
|
| 274 |
+
extra = "allow"
|
| 275 |
+
|
| 276 |
+
|
| 277 |
+
# Error Models
|
| 278 |
+
class APIError(BaseModel):
|
| 279 |
+
message: str
|
| 280 |
+
type: str = "api_error"
|
| 281 |
+
code: Optional[int] = None
|
| 282 |
+
|
| 283 |
+
|
| 284 |
+
class ErrorResponse(BaseModel):
|
| 285 |
+
error: APIError
|
| 286 |
+
|
| 287 |
+
|
| 288 |
+
# Control Panel Models
|
| 289 |
+
class SystemStatus(BaseModel):
|
| 290 |
+
status: str
|
| 291 |
+
timestamp: str
|
| 292 |
+
credentials: Dict[str, int]
|
| 293 |
+
config: Dict[str, Any]
|
| 294 |
+
current_credential: str
|
| 295 |
+
|
| 296 |
+
|
| 297 |
+
class CredentialInfo(BaseModel):
|
| 298 |
+
filename: str
|
| 299 |
+
project_id: Optional[str] = None
|
| 300 |
+
status: Dict[str, Any]
|
| 301 |
+
size: Optional[int] = None
|
| 302 |
+
modified_time: Optional[str] = None
|
| 303 |
+
error: Optional[str] = None
|
| 304 |
+
|
| 305 |
+
|
| 306 |
+
class LogEntry(BaseModel):
|
| 307 |
+
timestamp: str
|
| 308 |
+
level: str
|
| 309 |
+
message: str
|
| 310 |
+
module: Optional[str] = None
|
| 311 |
+
|
| 312 |
+
|
| 313 |
+
class ConfigValue(BaseModel):
|
| 314 |
+
key: str
|
| 315 |
+
value: Any
|
| 316 |
+
env_locked: bool = False
|
| 317 |
+
description: Optional[str] = None
|
| 318 |
+
|
| 319 |
+
|
| 320 |
+
# Authentication Models
|
| 321 |
+
class AuthRequest(BaseModel):
|
| 322 |
+
project_id: Optional[str] = None
|
| 323 |
+
user_session: Optional[str] = None
|
| 324 |
+
|
| 325 |
+
|
| 326 |
+
class AuthResponse(BaseModel):
|
| 327 |
+
success: bool
|
| 328 |
+
auth_url: Optional[str] = None
|
| 329 |
+
state: Optional[str] = None
|
| 330 |
+
error: Optional[str] = None
|
| 331 |
+
credentials: Optional[Dict[str, Any]] = None
|
| 332 |
+
file_path: Optional[str] = None
|
| 333 |
+
requires_manual_project_id: Optional[bool] = None
|
| 334 |
+
requires_project_selection: Optional[bool] = None
|
| 335 |
+
available_projects: Optional[List[Dict[str, str]]] = None
|
| 336 |
+
|
| 337 |
+
|
| 338 |
+
class CredentialStatus(BaseModel):
|
| 339 |
+
disabled: bool = False
|
| 340 |
+
error_codes: List[int] = []
|
| 341 |
+
last_success: Optional[str] = None
|
| 342 |
+
|
| 343 |
+
|
| 344 |
+
# Web Routes Models
|
| 345 |
+
class LoginRequest(BaseModel):
|
| 346 |
+
password: str
|
| 347 |
+
|
| 348 |
+
|
| 349 |
+
class AuthStartRequest(BaseModel):
|
| 350 |
+
project_id: Optional[str] = None # 现在是可选的
|
| 351 |
+
mode: Optional[str] = "geminicli" # 凭证模式: geminicli 或 antigravity
|
| 352 |
+
|
| 353 |
+
|
| 354 |
+
class AuthCallbackRequest(BaseModel):
|
| 355 |
+
project_id: Optional[str] = None # 现在是可��的
|
| 356 |
+
mode: Optional[str] = "geminicli" # 凭证模式: geminicli 或 antigravity
|
| 357 |
+
|
| 358 |
+
|
| 359 |
+
class AuthCallbackUrlRequest(BaseModel):
|
| 360 |
+
callback_url: str # OAuth回调完整URL
|
| 361 |
+
project_id: Optional[str] = None # 可选的项目ID
|
| 362 |
+
mode: Optional[str] = "geminicli" # 凭证模式: geminicli 或 antigravity
|
| 363 |
+
|
| 364 |
+
|
| 365 |
+
class CredFileActionRequest(BaseModel):
|
| 366 |
+
filename: str
|
| 367 |
+
action: str # enable, disable, delete
|
| 368 |
+
|
| 369 |
+
|
| 370 |
+
class CredFileBatchActionRequest(BaseModel):
|
| 371 |
+
action: str # "enable", "disable", "delete"
|
| 372 |
+
filenames: List[str] # 批量操作的文件名列表
|
| 373 |
+
|
| 374 |
+
|
| 375 |
+
class ConfigSaveRequest(BaseModel):
|
| 376 |
+
config: dict
|
src/router/antigravity/anthropic.py
ADDED
|
@@ -0,0 +1,566 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Anthropic Router - Handles Anthropic/Claude format API requests via Antigravity
|
| 3 |
+
通过Antigravity处理Anthropic/Claude格式请求的路由模块
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import sys
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
|
| 9 |
+
# 添加项目根目录到Python路径
|
| 10 |
+
project_root = Path(__file__).resolve().parent.parent.parent.parent
|
| 11 |
+
if str(project_root) not in sys.path:
|
| 12 |
+
sys.path.insert(0, str(project_root))
|
| 13 |
+
|
| 14 |
+
# 标准库
|
| 15 |
+
import asyncio
|
| 16 |
+
import json
|
| 17 |
+
|
| 18 |
+
# 第三方库
|
| 19 |
+
from fastapi import APIRouter, Depends, HTTPException
|
| 20 |
+
from fastapi.responses import JSONResponse, StreamingResponse
|
| 21 |
+
|
| 22 |
+
# 本地模块 - 配置和日志
|
| 23 |
+
from config import get_anti_truncation_max_attempts
|
| 24 |
+
from log import log
|
| 25 |
+
|
| 26 |
+
# 本地模块 - 工具和认证
|
| 27 |
+
from src.utils import (
|
| 28 |
+
get_base_model_from_feature_model,
|
| 29 |
+
is_anti_truncation_model,
|
| 30 |
+
is_fake_streaming_model,
|
| 31 |
+
authenticate_bearer,
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
# 本地模块 - 转换器(假流式需要)
|
| 35 |
+
from src.converter.fake_stream import (
|
| 36 |
+
parse_response_for_fake_stream,
|
| 37 |
+
build_anthropic_fake_stream_chunks,
|
| 38 |
+
create_anthropic_heartbeat_chunk,
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
# 本地模块 - 基础路由工具
|
| 42 |
+
from src.router.hi_check import is_health_check_request, create_health_check_response
|
| 43 |
+
|
| 44 |
+
# 本地模块 - 数据模型
|
| 45 |
+
from src.models import ClaudeRequest, model_to_dict
|
| 46 |
+
|
| 47 |
+
# 本地模块 - 任务管理
|
| 48 |
+
from src.task_manager import create_managed_task
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
# ==================== 路由器初始化 ====================
|
| 52 |
+
|
| 53 |
+
router = APIRouter()
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
# ==================== API 路由 ====================
|
| 57 |
+
|
| 58 |
+
@router.post("/antigravity/v1/messages")
|
| 59 |
+
async def messages(
|
| 60 |
+
claude_request: ClaudeRequest,
|
| 61 |
+
_token: str = Depends(authenticate_bearer)
|
| 62 |
+
):
|
| 63 |
+
"""
|
| 64 |
+
处理Anthropic/Claude格式的消息请求(流式和非流式)
|
| 65 |
+
|
| 66 |
+
Args:
|
| 67 |
+
claude_request: Anthropic/Claude格式的请求体
|
| 68 |
+
token: Bearer认证令牌
|
| 69 |
+
"""
|
| 70 |
+
log.debug(f"[ANTIGRAVITY-ANTHROPIC] Request for model: {claude_request.model}")
|
| 71 |
+
|
| 72 |
+
# 转换为字典
|
| 73 |
+
normalized_dict = model_to_dict(claude_request)
|
| 74 |
+
|
| 75 |
+
# 健康检查
|
| 76 |
+
if is_health_check_request(normalized_dict, format="anthropic"):
|
| 77 |
+
response = create_health_check_response(format="anthropic")
|
| 78 |
+
return JSONResponse(content=response)
|
| 79 |
+
|
| 80 |
+
# 处理模型名称和功能检测
|
| 81 |
+
use_fake_streaming = is_fake_streaming_model(claude_request.model)
|
| 82 |
+
use_anti_truncation = is_anti_truncation_model(claude_request.model)
|
| 83 |
+
real_model = get_base_model_from_feature_model(claude_request.model)
|
| 84 |
+
|
| 85 |
+
# 获取流式标志
|
| 86 |
+
is_streaming = claude_request.stream
|
| 87 |
+
|
| 88 |
+
# 对于抗截断模型的非流式请求,给出警告
|
| 89 |
+
if use_anti_truncation and not is_streaming:
|
| 90 |
+
log.warning("抗截断功能仅在流式传输时有效,非流式请求将忽略此设置")
|
| 91 |
+
|
| 92 |
+
# 更新模型名为真实模型名
|
| 93 |
+
normalized_dict["model"] = real_model
|
| 94 |
+
|
| 95 |
+
# 转换为 Gemini 格式 (使用 converter)
|
| 96 |
+
from src.converter.anthropic2gemini import anthropic_to_gemini_request
|
| 97 |
+
gemini_dict = await anthropic_to_gemini_request(normalized_dict)
|
| 98 |
+
|
| 99 |
+
# anthropic_to_gemini_request 不包含 model 字段,需要手动添加
|
| 100 |
+
gemini_dict["model"] = real_model
|
| 101 |
+
|
| 102 |
+
# 规范化 Gemini 请求 (使用 antigravity 模式)
|
| 103 |
+
from src.converter.gemini_fix import normalize_gemini_request
|
| 104 |
+
gemini_dict = await normalize_gemini_request(gemini_dict, mode="antigravity")
|
| 105 |
+
|
| 106 |
+
# 准备API请求格式 - 提取model并将其他字段放入request中
|
| 107 |
+
api_request = {
|
| 108 |
+
"model": gemini_dict.pop("model"),
|
| 109 |
+
"request": gemini_dict
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
# ========== 非流式请求 ==========
|
| 113 |
+
if not is_streaming:
|
| 114 |
+
# 调用 API 层的非流式请求
|
| 115 |
+
from src.api.antigravity import non_stream_request
|
| 116 |
+
response = await non_stream_request(body=api_request)
|
| 117 |
+
|
| 118 |
+
# 检查响应状态码
|
| 119 |
+
status_code = getattr(response, "status_code", 200)
|
| 120 |
+
|
| 121 |
+
# 提取响应体
|
| 122 |
+
if hasattr(response, "body"):
|
| 123 |
+
response_body = response.body.decode() if isinstance(response.body, bytes) else response.body
|
| 124 |
+
elif hasattr(response, "content"):
|
| 125 |
+
response_body = response.content.decode() if isinstance(response.content, bytes) else response.content
|
| 126 |
+
else:
|
| 127 |
+
response_body = str(response)
|
| 128 |
+
|
| 129 |
+
try:
|
| 130 |
+
gemini_response = json.loads(response_body)
|
| 131 |
+
except Exception as e:
|
| 132 |
+
log.error(f"Failed to parse Gemini response: {e}")
|
| 133 |
+
raise HTTPException(status_code=500, detail="Response parsing failed")
|
| 134 |
+
|
| 135 |
+
# 转换为 Anthropic 格式
|
| 136 |
+
from src.converter.anthropic2gemini import gemini_to_anthropic_response
|
| 137 |
+
anthropic_response = gemini_to_anthropic_response(
|
| 138 |
+
gemini_response,
|
| 139 |
+
real_model,
|
| 140 |
+
status_code
|
| 141 |
+
)
|
| 142 |
+
|
| 143 |
+
return JSONResponse(content=anthropic_response, status_code=status_code)
|
| 144 |
+
|
| 145 |
+
# ========== 流式请求 ==========
|
| 146 |
+
|
| 147 |
+
# ========== 假流式生成器 ==========
|
| 148 |
+
async def fake_stream_generator():
|
| 149 |
+
# 发送心跳
|
| 150 |
+
heartbeat = create_anthropic_heartbeat_chunk()
|
| 151 |
+
yield f"data: {json.dumps(heartbeat)}\n\n".encode()
|
| 152 |
+
|
| 153 |
+
# 异步发送实际请求
|
| 154 |
+
async def get_response():
|
| 155 |
+
from src.api.antigravity import non_stream_request
|
| 156 |
+
response = await non_stream_request(body=api_request)
|
| 157 |
+
return response
|
| 158 |
+
|
| 159 |
+
# 创建请求任务
|
| 160 |
+
response_task = create_managed_task(get_response(), name="anthropic_fake_stream_request")
|
| 161 |
+
|
| 162 |
+
try:
|
| 163 |
+
# 每3秒发送一次心跳,直到收到响应
|
| 164 |
+
while not response_task.done():
|
| 165 |
+
await asyncio.sleep(3.0)
|
| 166 |
+
if not response_task.done():
|
| 167 |
+
yield f"data: {json.dumps(heartbeat)}\n\n".encode()
|
| 168 |
+
|
| 169 |
+
# 获取响应结果
|
| 170 |
+
response = await response_task
|
| 171 |
+
|
| 172 |
+
except asyncio.CancelledError:
|
| 173 |
+
response_task.cancel()
|
| 174 |
+
try:
|
| 175 |
+
await response_task
|
| 176 |
+
except asyncio.CancelledError:
|
| 177 |
+
pass
|
| 178 |
+
raise
|
| 179 |
+
except Exception as e:
|
| 180 |
+
response_task.cancel()
|
| 181 |
+
try:
|
| 182 |
+
await response_task
|
| 183 |
+
except asyncio.CancelledError:
|
| 184 |
+
pass
|
| 185 |
+
log.error(f"Fake streaming request failed: {e}")
|
| 186 |
+
raise
|
| 187 |
+
|
| 188 |
+
# 检查响应状态码
|
| 189 |
+
if hasattr(response, "status_code") and response.status_code != 200:
|
| 190 |
+
# 错误响应 - 提取错误信息并以SSE格式返回
|
| 191 |
+
log.error(f"Fake streaming got error response: status={response.status_code}")
|
| 192 |
+
|
| 193 |
+
if hasattr(response, "body"):
|
| 194 |
+
error_body = response.body.decode() if isinstance(response.body, bytes) else response.body
|
| 195 |
+
elif hasattr(response, "content"):
|
| 196 |
+
error_body = response.content.decode() if isinstance(response.content, bytes) else response.content
|
| 197 |
+
else:
|
| 198 |
+
error_body = str(response)
|
| 199 |
+
|
| 200 |
+
try:
|
| 201 |
+
error_data = json.loads(error_body)
|
| 202 |
+
# 转换错误为 Anthropic 格式
|
| 203 |
+
from src.converter.anthropic2gemini import gemini_to_anthropic_response
|
| 204 |
+
anthropic_error = gemini_to_anthropic_response(
|
| 205 |
+
error_data,
|
| 206 |
+
real_model,
|
| 207 |
+
response.status_code
|
| 208 |
+
)
|
| 209 |
+
yield f"data: {json.dumps(anthropic_error)}\n\n".encode()
|
| 210 |
+
except Exception:
|
| 211 |
+
# 如果无法解析为JSON,包装成错误对象
|
| 212 |
+
yield f"data: {json.dumps({'error': error_body})}\n\n".encode()
|
| 213 |
+
|
| 214 |
+
yield "data: [DONE]\n\n".encode()
|
| 215 |
+
return
|
| 216 |
+
|
| 217 |
+
# 处理成功响应 - 提取响应内容
|
| 218 |
+
if hasattr(response, "body"):
|
| 219 |
+
response_body = response.body.decode() if isinstance(response.body, bytes) else response.body
|
| 220 |
+
elif hasattr(response, "content"):
|
| 221 |
+
response_body = response.content.decode() if isinstance(response.content, bytes) else response.content
|
| 222 |
+
else:
|
| 223 |
+
response_body = str(response)
|
| 224 |
+
|
| 225 |
+
try:
|
| 226 |
+
gemini_response = json.loads(response_body)
|
| 227 |
+
log.debug(f"Anthropic fake stream Gemini response: {gemini_response}")
|
| 228 |
+
|
| 229 |
+
# 检查是否是错误响应(有些错误可能status_code是200但包含error字段)
|
| 230 |
+
if "error" in gemini_response:
|
| 231 |
+
log.error(f"Fake streaming got error in response body: {gemini_response['error']}")
|
| 232 |
+
# 转换错误为 Anthropic 格式
|
| 233 |
+
from src.converter.anthropic2gemini import gemini_to_anthropic_response
|
| 234 |
+
anthropic_error = gemini_to_anthropic_response(
|
| 235 |
+
gemini_response,
|
| 236 |
+
real_model,
|
| 237 |
+
200
|
| 238 |
+
)
|
| 239 |
+
yield f"data: {json.dumps(anthropic_error)}\n\n".encode()
|
| 240 |
+
yield "data: [DONE]\n\n".encode()
|
| 241 |
+
return
|
| 242 |
+
|
| 243 |
+
# 使用统一的解析函数
|
| 244 |
+
content, reasoning_content, finish_reason, images = parse_response_for_fake_stream(gemini_response)
|
| 245 |
+
|
| 246 |
+
log.debug(f"Anthropic extracted content: {content}")
|
| 247 |
+
log.debug(f"Anthropic extracted reasoning: {reasoning_content[:100] if reasoning_content else 'None'}...")
|
| 248 |
+
log.debug(f"Anthropic extracted images count: {len(images)}")
|
| 249 |
+
|
| 250 |
+
# 构建响应块
|
| 251 |
+
chunks = build_anthropic_fake_stream_chunks(content, reasoning_content, finish_reason, real_model, images)
|
| 252 |
+
for idx, chunk in enumerate(chunks):
|
| 253 |
+
chunk_json = json.dumps(chunk)
|
| 254 |
+
log.debug(f"[FAKE_STREAM] Yielding chunk #{idx+1}: {chunk_json[:200]}")
|
| 255 |
+
yield f"data: {chunk_json}\n\n".encode()
|
| 256 |
+
|
| 257 |
+
except Exception as e:
|
| 258 |
+
log.error(f"Response parsing failed: {e}, directly yield error")
|
| 259 |
+
# 构建错误响应
|
| 260 |
+
error_chunk = {
|
| 261 |
+
"type": "error",
|
| 262 |
+
"error": {
|
| 263 |
+
"type": "api_error",
|
| 264 |
+
"message": str(e)
|
| 265 |
+
}
|
| 266 |
+
}
|
| 267 |
+
yield f"data: {json.dumps(error_chunk)}\n\n".encode()
|
| 268 |
+
|
| 269 |
+
yield "data: [DONE]\n\n".encode()
|
| 270 |
+
|
| 271 |
+
# ========== 流式抗截断生成器 ==========
|
| 272 |
+
async def anti_truncation_generator():
|
| 273 |
+
from src.converter.anti_truncation import AntiTruncationStreamProcessor
|
| 274 |
+
from src.api.antigravity import stream_request
|
| 275 |
+
from src.converter.anti_truncation import apply_anti_truncation
|
| 276 |
+
from src.converter.anthropic2gemini import gemini_stream_to_anthropic_stream
|
| 277 |
+
|
| 278 |
+
max_attempts = await get_anti_truncation_max_attempts()
|
| 279 |
+
|
| 280 |
+
# 首先对payload应用反截断指令
|
| 281 |
+
anti_truncation_payload = apply_anti_truncation(api_request)
|
| 282 |
+
|
| 283 |
+
# 定义流式请求函数(返回 StreamingResponse)
|
| 284 |
+
async def stream_request_wrapper(payload):
|
| 285 |
+
# stream_request 返回异步生成器,需要包装成 StreamingResponse
|
| 286 |
+
stream_gen = stream_request(body=payload, native=False)
|
| 287 |
+
return StreamingResponse(stream_gen, media_type="text/event-stream")
|
| 288 |
+
|
| 289 |
+
# 创建反截断处理器
|
| 290 |
+
processor = AntiTruncationStreamProcessor(
|
| 291 |
+
stream_request_wrapper,
|
| 292 |
+
anti_truncation_payload,
|
| 293 |
+
max_attempts
|
| 294 |
+
)
|
| 295 |
+
|
| 296 |
+
# 包装以确保是bytes流
|
| 297 |
+
async def bytes_wrapper():
|
| 298 |
+
async for chunk in processor.process_stream():
|
| 299 |
+
if isinstance(chunk, str):
|
| 300 |
+
yield chunk.encode('utf-8')
|
| 301 |
+
else:
|
| 302 |
+
yield chunk
|
| 303 |
+
|
| 304 |
+
# 直接将整个流传递给转换器
|
| 305 |
+
async for anthropic_chunk in gemini_stream_to_anthropic_stream(
|
| 306 |
+
bytes_wrapper(),
|
| 307 |
+
real_model,
|
| 308 |
+
200
|
| 309 |
+
):
|
| 310 |
+
if anthropic_chunk:
|
| 311 |
+
yield anthropic_chunk
|
| 312 |
+
|
| 313 |
+
# ========== 普通流式生成器 ==========
|
| 314 |
+
async def normal_stream_generator():
|
| 315 |
+
from src.api.antigravity import stream_request
|
| 316 |
+
from fastapi import Response
|
| 317 |
+
from src.converter.anthropic2gemini import gemini_stream_to_anthropic_stream
|
| 318 |
+
|
| 319 |
+
# 调用 API 层的流式请求(不使用 native 模式)
|
| 320 |
+
stream_gen = stream_request(body=api_request, native=False)
|
| 321 |
+
|
| 322 |
+
# 包装流式生成器以处理错误响应
|
| 323 |
+
async def gemini_chunk_wrapper():
|
| 324 |
+
async for chunk in stream_gen:
|
| 325 |
+
# 检查是否是Response对象(错误情况)
|
| 326 |
+
if isinstance(chunk, Response):
|
| 327 |
+
# 错误响应,不进行转换,直接传递
|
| 328 |
+
error_content = chunk.body if isinstance(chunk.body, bytes) else chunk.body.encode('utf-8')
|
| 329 |
+
try:
|
| 330 |
+
gemini_error = json.loads(error_content.decode('utf-8'))
|
| 331 |
+
from src.converter.anthropic2gemini import gemini_to_anthropic_response
|
| 332 |
+
anthropic_error = gemini_to_anthropic_response(
|
| 333 |
+
gemini_error,
|
| 334 |
+
real_model,
|
| 335 |
+
chunk.status_code
|
| 336 |
+
)
|
| 337 |
+
yield f"data: {json.dumps(anthropic_error)}\n\n".encode('utf-8')
|
| 338 |
+
except Exception:
|
| 339 |
+
yield f"data: {json.dumps({'type': 'error', 'error': {'type': 'api_error', 'message': 'Stream error'}})}\n\n".encode('utf-8')
|
| 340 |
+
return
|
| 341 |
+
else:
|
| 342 |
+
# 确保是bytes类型
|
| 343 |
+
if isinstance(chunk, str):
|
| 344 |
+
yield chunk.encode('utf-8')
|
| 345 |
+
else:
|
| 346 |
+
yield chunk
|
| 347 |
+
|
| 348 |
+
# 使用转换器处理整个流
|
| 349 |
+
async for anthropic_chunk in gemini_stream_to_anthropic_stream(
|
| 350 |
+
gemini_chunk_wrapper(),
|
| 351 |
+
real_model,
|
| 352 |
+
200
|
| 353 |
+
):
|
| 354 |
+
if anthropic_chunk:
|
| 355 |
+
yield anthropic_chunk
|
| 356 |
+
|
| 357 |
+
# ========== 根据模式选择生成器 ==========
|
| 358 |
+
if use_fake_streaming:
|
| 359 |
+
return StreamingResponse(fake_stream_generator(), media_type="text/event-stream")
|
| 360 |
+
elif use_anti_truncation:
|
| 361 |
+
log.info("启用流式抗截断功能")
|
| 362 |
+
return StreamingResponse(anti_truncation_generator(), media_type="text/event-stream")
|
| 363 |
+
else:
|
| 364 |
+
return StreamingResponse(normal_stream_generator(), media_type="text/event-stream")
|
| 365 |
+
|
| 366 |
+
|
| 367 |
+
# ==================== 测试代码 ====================
|
| 368 |
+
|
| 369 |
+
if __name__ == "__main__":
|
| 370 |
+
"""
|
| 371 |
+
测试代码:演示Anthropic路由的流式和非流式响应
|
| 372 |
+
运行方式: python src/router/antigravity/anthropic.py
|
| 373 |
+
"""
|
| 374 |
+
|
| 375 |
+
from fastapi.testclient import TestClient
|
| 376 |
+
from fastapi import FastAPI
|
| 377 |
+
|
| 378 |
+
print("=" * 80)
|
| 379 |
+
print("Anthropic Router 测试")
|
| 380 |
+
print("=" * 80)
|
| 381 |
+
|
| 382 |
+
# 创建测试应用
|
| 383 |
+
app = FastAPI()
|
| 384 |
+
app.include_router(router)
|
| 385 |
+
|
| 386 |
+
# 测试客户端
|
| 387 |
+
client = TestClient(app)
|
| 388 |
+
|
| 389 |
+
# 测试请求体 (Anthropic格式)
|
| 390 |
+
test_request_body = {
|
| 391 |
+
"model": "gemini-2.5-flash",
|
| 392 |
+
"max_tokens": 1024,
|
| 393 |
+
"messages": [
|
| 394 |
+
{"role": "user", "content": "Hello, tell me a joke in one sentence."}
|
| 395 |
+
]
|
| 396 |
+
}
|
| 397 |
+
|
| 398 |
+
# 测试Bearer令牌(模拟)
|
| 399 |
+
test_token = "Bearer pwd"
|
| 400 |
+
|
| 401 |
+
def test_non_stream_request():
|
| 402 |
+
"""测试非流式请求"""
|
| 403 |
+
print("\n" + "=" * 80)
|
| 404 |
+
print("【测试1】非流式请求 (POST /antigravity/v1/messages)")
|
| 405 |
+
print("=" * 80)
|
| 406 |
+
print(f"请求体: {json.dumps(test_request_body, indent=2, ensure_ascii=False)}\n")
|
| 407 |
+
|
| 408 |
+
response = client.post(
|
| 409 |
+
"/antigravity/v1/messages",
|
| 410 |
+
json=test_request_body,
|
| 411 |
+
headers={"Authorization": test_token}
|
| 412 |
+
)
|
| 413 |
+
|
| 414 |
+
print("非流式响应数据:")
|
| 415 |
+
print("-" * 80)
|
| 416 |
+
print(f"状态码: {response.status_code}")
|
| 417 |
+
print(f"Content-Type: {response.headers.get('content-type', 'N/A')}")
|
| 418 |
+
|
| 419 |
+
try:
|
| 420 |
+
content = response.text
|
| 421 |
+
print(f"\n响应内容 (原始):\n{content}\n")
|
| 422 |
+
|
| 423 |
+
# 尝试解析JSON
|
| 424 |
+
try:
|
| 425 |
+
json_data = response.json()
|
| 426 |
+
print(f"响应内容 (格式化JSON):")
|
| 427 |
+
print(json.dumps(json_data, indent=2, ensure_ascii=False))
|
| 428 |
+
except json.JSONDecodeError:
|
| 429 |
+
print("(非JSON格式)")
|
| 430 |
+
except Exception as e:
|
| 431 |
+
print(f"内容解析失败: {e}")
|
| 432 |
+
|
| 433 |
+
def test_stream_request():
|
| 434 |
+
"""测试流式请求"""
|
| 435 |
+
print("\n" + "=" * 80)
|
| 436 |
+
print("【测试2】流式请求 (POST /antigravity/v1/messages)")
|
| 437 |
+
print("=" * 80)
|
| 438 |
+
|
| 439 |
+
stream_request_body = test_request_body.copy()
|
| 440 |
+
stream_request_body["stream"] = True
|
| 441 |
+
|
| 442 |
+
print(f"请求体: {json.dumps(stream_request_body, indent=2, ensure_ascii=False)}\n")
|
| 443 |
+
|
| 444 |
+
print("流式响应数据 (每个chunk):")
|
| 445 |
+
print("-" * 80)
|
| 446 |
+
|
| 447 |
+
with client.stream(
|
| 448 |
+
"POST",
|
| 449 |
+
"/antigravity/v1/messages",
|
| 450 |
+
json=stream_request_body,
|
| 451 |
+
headers={"Authorization": test_token}
|
| 452 |
+
) as response:
|
| 453 |
+
print(f"状态码: {response.status_code}")
|
| 454 |
+
print(f"Content-Type: {response.headers.get('content-type', 'N/A')}\n")
|
| 455 |
+
|
| 456 |
+
chunk_count = 0
|
| 457 |
+
for chunk in response.iter_bytes():
|
| 458 |
+
if chunk:
|
| 459 |
+
chunk_count += 1
|
| 460 |
+
print(f"\nChunk #{chunk_count}:")
|
| 461 |
+
print(f" 类型: {type(chunk).__name__}")
|
| 462 |
+
print(f" 长度: {len(chunk)}")
|
| 463 |
+
|
| 464 |
+
# 解码chunk
|
| 465 |
+
try:
|
| 466 |
+
chunk_str = chunk.decode('utf-8')
|
| 467 |
+
print(f" 内容预览: {repr(chunk_str[:200] if len(chunk_str) > 200 else chunk_str)}")
|
| 468 |
+
|
| 469 |
+
# 如果是SSE格式,尝试解析每一行
|
| 470 |
+
if chunk_str.startswith("event: ") or chunk_str.startswith("data: "):
|
| 471 |
+
# 按行分割,处理每个SSE事件
|
| 472 |
+
for line in chunk_str.strip().split('\n'):
|
| 473 |
+
line = line.strip()
|
| 474 |
+
if not line:
|
| 475 |
+
continue
|
| 476 |
+
|
| 477 |
+
if line == "data: [DONE]":
|
| 478 |
+
print(f" => 流结束标记")
|
| 479 |
+
elif line.startswith("data: "):
|
| 480 |
+
try:
|
| 481 |
+
json_str = line[6:] # 去掉 "data: " 前缀
|
| 482 |
+
json_data = json.loads(json_str)
|
| 483 |
+
print(f" 解析后的JSON: {json.dumps(json_data, indent=4, ensure_ascii=False)}")
|
| 484 |
+
except Exception as e:
|
| 485 |
+
print(f" SSE解析失败: {e}")
|
| 486 |
+
except Exception as e:
|
| 487 |
+
print(f" 解码失败: {e}")
|
| 488 |
+
|
| 489 |
+
print(f"\n总共收到 {chunk_count} 个chunk")
|
| 490 |
+
|
| 491 |
+
def test_fake_stream_request():
|
| 492 |
+
"""测试假流式请求"""
|
| 493 |
+
print("\n" + "=" * 80)
|
| 494 |
+
print("【测试3】假流式请求 (POST /antigravity/v1/messages with 假流式 prefix)")
|
| 495 |
+
print("=" * 80)
|
| 496 |
+
|
| 497 |
+
fake_stream_request_body = test_request_body.copy()
|
| 498 |
+
fake_stream_request_body["model"] = "假流式/gemini-2.5-flash"
|
| 499 |
+
fake_stream_request_body["stream"] = True
|
| 500 |
+
|
| 501 |
+
print(f"请求体: {json.dumps(fake_stream_request_body, indent=2, ensure_ascii=False)}\n")
|
| 502 |
+
|
| 503 |
+
print("假流式响应数据 (每个chunk):")
|
| 504 |
+
print("-" * 80)
|
| 505 |
+
|
| 506 |
+
with client.stream(
|
| 507 |
+
"POST",
|
| 508 |
+
"/antigravity/v1/messages",
|
| 509 |
+
json=fake_stream_request_body,
|
| 510 |
+
headers={"Authorization": test_token}
|
| 511 |
+
) as response:
|
| 512 |
+
print(f"状态码: {response.status_code}")
|
| 513 |
+
print(f"Content-Type: {response.headers.get('content-type', 'N/A')}\n")
|
| 514 |
+
|
| 515 |
+
chunk_count = 0
|
| 516 |
+
for chunk in response.iter_bytes():
|
| 517 |
+
if chunk:
|
| 518 |
+
chunk_count += 1
|
| 519 |
+
chunk_str = chunk.decode('utf-8')
|
| 520 |
+
|
| 521 |
+
print(f"\nChunk #{chunk_count}:")
|
| 522 |
+
print(f" 长度: {len(chunk_str)} 字节")
|
| 523 |
+
|
| 524 |
+
# 解析chunk中的所有SSE事件
|
| 525 |
+
events = []
|
| 526 |
+
for line in chunk_str.split('\n'):
|
| 527 |
+
line = line.strip()
|
| 528 |
+
if line.startswith("data: ") or line.startswith("event: "):
|
| 529 |
+
events.append(line)
|
| 530 |
+
|
| 531 |
+
print(f" 包含 {len(events)} 个SSE事件")
|
| 532 |
+
|
| 533 |
+
# 显示每个事件
|
| 534 |
+
for event_idx, event_line in enumerate(events, 1):
|
| 535 |
+
if event_line == "data: [DONE]":
|
| 536 |
+
print(f" 事件 #{event_idx}: [DONE]")
|
| 537 |
+
elif event_line.startswith("data: "):
|
| 538 |
+
try:
|
| 539 |
+
json_str = event_line[6:] # 去掉 "data: " 前缀
|
| 540 |
+
json_data = json.loads(json_str)
|
| 541 |
+
event_type = json_data.get("type", "unknown")
|
| 542 |
+
print(f" 事件 #{event_idx}: type={event_type}")
|
| 543 |
+
except Exception as e:
|
| 544 |
+
print(f" 事件 #{event_idx}: 解析失败 - {e}")
|
| 545 |
+
|
| 546 |
+
print(f"\n总共收到 {chunk_count} 个HTTP chunk")
|
| 547 |
+
|
| 548 |
+
# 运行测试
|
| 549 |
+
try:
|
| 550 |
+
# 测试非流式请求
|
| 551 |
+
test_non_stream_request()
|
| 552 |
+
|
| 553 |
+
# 测试流式请求
|
| 554 |
+
test_stream_request()
|
| 555 |
+
|
| 556 |
+
# 测试假流式请求
|
| 557 |
+
test_fake_stream_request()
|
| 558 |
+
|
| 559 |
+
print("\n" + "=" * 80)
|
| 560 |
+
print("测试完成")
|
| 561 |
+
print("=" * 80)
|
| 562 |
+
|
| 563 |
+
except Exception as e:
|
| 564 |
+
print(f"\n❌ 测试过程中出现异常: {e}")
|
| 565 |
+
import traceback
|
| 566 |
+
traceback.print_exc()
|
src/router/antigravity/gemini.py
ADDED
|
@@ -0,0 +1,690 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Gemini Router - Handles native Gemini format API requests (Antigravity backend)
|
| 3 |
+
处理原生Gemini格式请求的路由模块(Antigravity后端)
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import sys
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
|
| 9 |
+
# 添加项目根目录到Python路径
|
| 10 |
+
project_root = Path(__file__).resolve().parent.parent.parent.parent
|
| 11 |
+
if str(project_root) not in sys.path:
|
| 12 |
+
sys.path.insert(0, str(project_root))
|
| 13 |
+
|
| 14 |
+
# 标准库
|
| 15 |
+
import asyncio
|
| 16 |
+
import json
|
| 17 |
+
|
| 18 |
+
# 第三方库
|
| 19 |
+
from fastapi import APIRouter, Depends, HTTPException, Path, Request
|
| 20 |
+
from fastapi.responses import JSONResponse, StreamingResponse
|
| 21 |
+
|
| 22 |
+
# 本地模块 - 配置和日志
|
| 23 |
+
from config import get_anti_truncation_max_attempts
|
| 24 |
+
from log import log
|
| 25 |
+
|
| 26 |
+
# 本地模块 - 工具和认证
|
| 27 |
+
from src.utils import (
|
| 28 |
+
get_base_model_from_feature_model,
|
| 29 |
+
is_anti_truncation_model,
|
| 30 |
+
authenticate_gemini_flexible,
|
| 31 |
+
is_fake_streaming_model
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
# 本地模块 - 转换器(假流式需要)
|
| 35 |
+
from src.converter.fake_stream import (
|
| 36 |
+
parse_response_for_fake_stream,
|
| 37 |
+
build_gemini_fake_stream_chunks,
|
| 38 |
+
create_gemini_heartbeat_chunk,
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
# 本地模块 - 基础路由工具
|
| 42 |
+
from src.router.hi_check import is_health_check_request, create_health_check_response
|
| 43 |
+
|
| 44 |
+
# 本地模块 - 数据模型
|
| 45 |
+
from src.models import GeminiRequest, model_to_dict
|
| 46 |
+
|
| 47 |
+
# 本地模块 - 任务管理
|
| 48 |
+
from src.task_manager import create_managed_task
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
# ==================== 路由器初始化 ====================
|
| 52 |
+
|
| 53 |
+
router = APIRouter()
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
# ==================== API 路由 ====================
|
| 57 |
+
|
| 58 |
+
@router.post("/antigravity/v1beta/models/{model:path}:generateContent")
|
| 59 |
+
@router.post("/antigravity/v1/models/{model:path}:generateContent")
|
| 60 |
+
async def generate_content(
|
| 61 |
+
gemini_request: "GeminiRequest",
|
| 62 |
+
model: str = Path(..., description="Model name"),
|
| 63 |
+
api_key: str = Depends(authenticate_gemini_flexible),
|
| 64 |
+
):
|
| 65 |
+
"""
|
| 66 |
+
处理Gemini格式的内容生成请求(非流式)
|
| 67 |
+
|
| 68 |
+
Args:
|
| 69 |
+
gemini_request: Gemini格式的请求体
|
| 70 |
+
model: 模型名称
|
| 71 |
+
api_key: API 密钥
|
| 72 |
+
"""
|
| 73 |
+
log.debug(f"[ANTIGRAVITY] Non-streaming request for model: {model}")
|
| 74 |
+
|
| 75 |
+
# 转换为字典
|
| 76 |
+
normalized_dict = model_to_dict(gemini_request)
|
| 77 |
+
|
| 78 |
+
# 健康检查
|
| 79 |
+
if is_health_check_request(normalized_dict, format="gemini"):
|
| 80 |
+
response = create_health_check_response(format="gemini")
|
| 81 |
+
return JSONResponse(content=response)
|
| 82 |
+
|
| 83 |
+
# 处理模型名称和功能检测
|
| 84 |
+
use_anti_truncation = is_anti_truncation_model(model)
|
| 85 |
+
real_model = get_base_model_from_feature_model(model)
|
| 86 |
+
|
| 87 |
+
# 对于抗截断模型的非流式请求,给出警告
|
| 88 |
+
if use_anti_truncation:
|
| 89 |
+
log.warning("抗截断功能仅在流式传输时有效,非流式请求将忽略此设置")
|
| 90 |
+
|
| 91 |
+
# 更新模型名为真实模型名
|
| 92 |
+
normalized_dict["model"] = real_model
|
| 93 |
+
|
| 94 |
+
# 规范化 Gemini 请求 (使用 antigravity 模式)
|
| 95 |
+
from src.converter.gemini_fix import normalize_gemini_request
|
| 96 |
+
normalized_dict = await normalize_gemini_request(normalized_dict, mode="antigravity")
|
| 97 |
+
|
| 98 |
+
# 准备API请求格式 - 提取model并将其他字段放入request中
|
| 99 |
+
api_request = {
|
| 100 |
+
"model": normalized_dict.pop("model"),
|
| 101 |
+
"request": normalized_dict
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
# 调用 API 层的非流式请求
|
| 105 |
+
from src.api.antigravity import non_stream_request
|
| 106 |
+
response = await non_stream_request(body=api_request)
|
| 107 |
+
|
| 108 |
+
# 直接返回响应(response已经是FastAPI Response对象)
|
| 109 |
+
# 保持 Gemini 原生的 inlineData 格式,不进行 Markdown 转换
|
| 110 |
+
return response
|
| 111 |
+
|
| 112 |
+
@router.post("/antigravity/v1beta/models/{model:path}:streamGenerateContent")
|
| 113 |
+
@router.post("/antigravity/v1/models/{model:path}:streamGenerateContent")
|
| 114 |
+
async def stream_generate_content(
|
| 115 |
+
gemini_request: GeminiRequest,
|
| 116 |
+
model: str = Path(..., description="Model name"),
|
| 117 |
+
api_key: str = Depends(authenticate_gemini_flexible),
|
| 118 |
+
):
|
| 119 |
+
"""
|
| 120 |
+
处理Gemini格式的流式内容生成请求
|
| 121 |
+
|
| 122 |
+
Args:
|
| 123 |
+
gemini_request: Gemini格式的请求体
|
| 124 |
+
model: 模型名称
|
| 125 |
+
api_key: API 密钥
|
| 126 |
+
"""
|
| 127 |
+
log.debug(f"[ANTIGRAVITY] Streaming request for model: {model}")
|
| 128 |
+
|
| 129 |
+
# 转换为字典
|
| 130 |
+
normalized_dict = model_to_dict(gemini_request)
|
| 131 |
+
|
| 132 |
+
# 处理模型名称和功能检测
|
| 133 |
+
use_fake_streaming = is_fake_streaming_model(model)
|
| 134 |
+
use_anti_truncation = is_anti_truncation_model(model)
|
| 135 |
+
real_model = get_base_model_from_feature_model(model)
|
| 136 |
+
|
| 137 |
+
# 更新模型名为真实模型名
|
| 138 |
+
normalized_dict["model"] = real_model
|
| 139 |
+
|
| 140 |
+
# ========== 假流式生成器 ==========
|
| 141 |
+
async def fake_stream_generator():
|
| 142 |
+
from src.converter.gemini_fix import normalize_gemini_request
|
| 143 |
+
normalized_req = await normalize_gemini_request(normalized_dict.copy(), mode="antigravity")
|
| 144 |
+
|
| 145 |
+
# 准备API请求格式 - 提取model并将其他字段放入request中
|
| 146 |
+
api_request = {
|
| 147 |
+
"model": normalized_req.pop("model"),
|
| 148 |
+
"request": normalized_req
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
# 发送心跳
|
| 152 |
+
heartbeat = create_gemini_heartbeat_chunk()
|
| 153 |
+
yield f"data: {json.dumps(heartbeat)}\n\n".encode()
|
| 154 |
+
|
| 155 |
+
# 异步发送实际请求
|
| 156 |
+
async def get_response():
|
| 157 |
+
from src.api.antigravity import non_stream_request
|
| 158 |
+
response = await non_stream_request(body=api_request)
|
| 159 |
+
return response
|
| 160 |
+
|
| 161 |
+
# 创建请求任务
|
| 162 |
+
response_task = create_managed_task(get_response(), name="gemini_fake_stream_request")
|
| 163 |
+
|
| 164 |
+
try:
|
| 165 |
+
# 每3秒发送一次心跳,直到收到响应
|
| 166 |
+
while not response_task.done():
|
| 167 |
+
await asyncio.sleep(3.0)
|
| 168 |
+
if not response_task.done():
|
| 169 |
+
yield f"data: {json.dumps(heartbeat)}\n\n".encode()
|
| 170 |
+
|
| 171 |
+
# 获取响应结果
|
| 172 |
+
response = await response_task
|
| 173 |
+
|
| 174 |
+
except asyncio.CancelledError:
|
| 175 |
+
response_task.cancel()
|
| 176 |
+
try:
|
| 177 |
+
await response_task
|
| 178 |
+
except asyncio.CancelledError:
|
| 179 |
+
pass
|
| 180 |
+
raise
|
| 181 |
+
except Exception as e:
|
| 182 |
+
response_task.cancel()
|
| 183 |
+
try:
|
| 184 |
+
await response_task
|
| 185 |
+
except asyncio.CancelledError:
|
| 186 |
+
pass
|
| 187 |
+
log.error(f"Fake streaming request failed: {e}")
|
| 188 |
+
raise
|
| 189 |
+
|
| 190 |
+
# 检查响应状态码
|
| 191 |
+
if hasattr(response, "status_code") and response.status_code != 200:
|
| 192 |
+
# 错误响应 - 提取错误信息并以SSE格式返回
|
| 193 |
+
log.error(f"Fake streaming got error response: status={response.status_code}")
|
| 194 |
+
|
| 195 |
+
if hasattr(response, "body"):
|
| 196 |
+
error_body = response.body.decode() if isinstance(response.body, bytes) else response.body
|
| 197 |
+
elif hasattr(response, "content"):
|
| 198 |
+
error_body = response.content.decode() if isinstance(response.content, bytes) else response.content
|
| 199 |
+
else:
|
| 200 |
+
error_body = str(response)
|
| 201 |
+
|
| 202 |
+
try:
|
| 203 |
+
error_data = json.loads(error_body)
|
| 204 |
+
# 以SSE格式返回错误
|
| 205 |
+
yield f"data: {json.dumps(error_data)}\n\n".encode()
|
| 206 |
+
except Exception:
|
| 207 |
+
# 如果无法解析为JSON,包装成错误对象
|
| 208 |
+
yield f"data: {json.dumps({'error': error_body})}\n\n".encode()
|
| 209 |
+
|
| 210 |
+
yield "data: [DONE]\n\n".encode()
|
| 211 |
+
return
|
| 212 |
+
|
| 213 |
+
# 处理成功响应 - 提取响应内容
|
| 214 |
+
if hasattr(response, "body"):
|
| 215 |
+
response_body = response.body.decode() if isinstance(response.body, bytes) else response.body
|
| 216 |
+
elif hasattr(response, "content"):
|
| 217 |
+
response_body = response.content.decode() if isinstance(response.content, bytes) else response.content
|
| 218 |
+
else:
|
| 219 |
+
response_body = str(response)
|
| 220 |
+
|
| 221 |
+
try:
|
| 222 |
+
response_data = json.loads(response_body)
|
| 223 |
+
log.debug(f"Gemini fake stream response data: {response_data}")
|
| 224 |
+
|
| 225 |
+
# 检查是否是错误响应(有些错误可能status_code是200但包含error字段)
|
| 226 |
+
if "error" in response_data:
|
| 227 |
+
log.error(f"Fake streaming got error in response body: {response_data['error']}")
|
| 228 |
+
yield f"data: {json.dumps(response_data)}\n\n".encode()
|
| 229 |
+
yield "data: [DONE]\n\n".encode()
|
| 230 |
+
return
|
| 231 |
+
|
| 232 |
+
# 使用统一的解析函数
|
| 233 |
+
content, reasoning_content, finish_reason, images = parse_response_for_fake_stream(response_data)
|
| 234 |
+
|
| 235 |
+
log.debug(f"Gemini extracted content: {content}")
|
| 236 |
+
log.debug(f"Gemini extracted reasoning: {reasoning_content[:100] if reasoning_content else 'None'}...")
|
| 237 |
+
log.debug(f"Gemini extracted images count: {len(images)}")
|
| 238 |
+
|
| 239 |
+
# 构建响应块
|
| 240 |
+
chunks = build_gemini_fake_stream_chunks(content, reasoning_content, finish_reason, images)
|
| 241 |
+
for idx, chunk in enumerate(chunks):
|
| 242 |
+
chunk_json = json.dumps(chunk)
|
| 243 |
+
log.debug(f"[FAKE_STREAM] Yielding chunk #{idx+1}: {chunk_json[:200]}")
|
| 244 |
+
yield f"data: {chunk_json}\n\n".encode()
|
| 245 |
+
|
| 246 |
+
except Exception as e:
|
| 247 |
+
log.error(f"Response parsing failed: {e}, directly yield original response")
|
| 248 |
+
# 直接yield原始响应,不进行包装
|
| 249 |
+
yield f"data: {response_body}\n\n".encode()
|
| 250 |
+
|
| 251 |
+
yield "data: [DONE]\n\n".encode()
|
| 252 |
+
|
| 253 |
+
# ========== 流式抗截断生成器 ==========
|
| 254 |
+
async def anti_truncation_generator():
|
| 255 |
+
from src.converter.gemini_fix import normalize_gemini_request
|
| 256 |
+
from src.converter.anti_truncation import AntiTruncationStreamProcessor
|
| 257 |
+
from src.converter.anti_truncation import apply_anti_truncation
|
| 258 |
+
from src.api.antigravity import stream_request
|
| 259 |
+
|
| 260 |
+
# 先进行基础标准化
|
| 261 |
+
normalized_req = await normalize_gemini_request(normalized_dict.copy(), mode="antigravity")
|
| 262 |
+
|
| 263 |
+
# 准备API请求格式 - 提取model并将其他字段放入request中
|
| 264 |
+
api_request = {
|
| 265 |
+
"model": normalized_req.pop("model") if "model" in normalized_req else real_model,
|
| 266 |
+
"request": normalized_req
|
| 267 |
+
}
|
| 268 |
+
|
| 269 |
+
max_attempts = await get_anti_truncation_max_attempts()
|
| 270 |
+
|
| 271 |
+
# 首先对payload应用反截断指令
|
| 272 |
+
anti_truncation_payload = apply_anti_truncation(api_request)
|
| 273 |
+
|
| 274 |
+
# 定义流式请求函数(返回 StreamingResponse)
|
| 275 |
+
async def stream_request_wrapper(payload):
|
| 276 |
+
# stream_request 返回异步生成器,需要包装成 StreamingResponse
|
| 277 |
+
stream_gen = stream_request(body=payload, native=False)
|
| 278 |
+
return StreamingResponse(stream_gen, media_type="text/event-stream")
|
| 279 |
+
|
| 280 |
+
# 创建反截断处理器
|
| 281 |
+
processor = AntiTruncationStreamProcessor(
|
| 282 |
+
stream_request_wrapper,
|
| 283 |
+
anti_truncation_payload,
|
| 284 |
+
max_attempts
|
| 285 |
+
)
|
| 286 |
+
|
| 287 |
+
# 迭代 process_stream() 生成器,并展开 response 包装
|
| 288 |
+
async for chunk in processor.process_stream():
|
| 289 |
+
if isinstance(chunk, (str, bytes)):
|
| 290 |
+
chunk_str = chunk.decode('utf-8') if isinstance(chunk, bytes) else chunk
|
| 291 |
+
|
| 292 |
+
# 解析并展开 response 包装
|
| 293 |
+
if chunk_str.startswith("data: "):
|
| 294 |
+
json_str = chunk_str[6:].strip()
|
| 295 |
+
|
| 296 |
+
# 跳过 [DONE] 标记
|
| 297 |
+
if json_str == "[DONE]":
|
| 298 |
+
yield chunk
|
| 299 |
+
continue
|
| 300 |
+
|
| 301 |
+
try:
|
| 302 |
+
# 解析JSON
|
| 303 |
+
data = json.loads(json_str)
|
| 304 |
+
|
| 305 |
+
# 展开 response 包装
|
| 306 |
+
if "response" in data and "candidates" not in data:
|
| 307 |
+
log.debug(f"[ANTIGRAVITY-ANTI-TRUNCATION] 展开response包装")
|
| 308 |
+
unwrapped_data = data["response"]
|
| 309 |
+
# 重新构建SSE格式
|
| 310 |
+
yield f"data: {json.dumps(unwrapped_data, ensure_ascii=False)}\n\n".encode('utf-8')
|
| 311 |
+
else:
|
| 312 |
+
# 已经是展开的格式,直接返回
|
| 313 |
+
yield chunk
|
| 314 |
+
except json.JSONDecodeError:
|
| 315 |
+
# JSON解析失败,直接返回原始chunk
|
| 316 |
+
yield chunk
|
| 317 |
+
else:
|
| 318 |
+
# 不是SSE格式,直接返回
|
| 319 |
+
yield chunk
|
| 320 |
+
else:
|
| 321 |
+
# 其他类型,直接返回
|
| 322 |
+
yield chunk
|
| 323 |
+
|
| 324 |
+
# ========== 普通流式生成器 ==========
|
| 325 |
+
async def normal_stream_generator():
|
| 326 |
+
from src.converter.gemini_fix import normalize_gemini_request
|
| 327 |
+
from src.api.antigravity import stream_request
|
| 328 |
+
from fastapi import Response
|
| 329 |
+
|
| 330 |
+
normalized_req = await normalize_gemini_request(normalized_dict.copy(), mode="antigravity")
|
| 331 |
+
|
| 332 |
+
# 准备API请求格式 - 提取model并将其他字段放入request中
|
| 333 |
+
api_request = {
|
| 334 |
+
"model": normalized_req.pop("model"),
|
| 335 |
+
"request": normalized_req
|
| 336 |
+
}
|
| 337 |
+
|
| 338 |
+
# 所有流式请求都使用非 native 模式(SSE格式)并展开 response 包装
|
| 339 |
+
log.debug(f"[ANTIGRAVITY] 使用非native模式,将展开response包装")
|
| 340 |
+
stream_gen = stream_request(body=api_request, native=False)
|
| 341 |
+
|
| 342 |
+
# 展开 response 包装
|
| 343 |
+
async for chunk in stream_gen:
|
| 344 |
+
# 检查是否是Response对象(错误情况)
|
| 345 |
+
if isinstance(chunk, Response):
|
| 346 |
+
# 将Response转换为SSE格式的错误消息
|
| 347 |
+
error_content = chunk.body if isinstance(chunk.body, bytes) else chunk.body.encode('utf-8')
|
| 348 |
+
error_json = json.loads(error_content.decode('utf-8'))
|
| 349 |
+
# 以SSE格式返回错误
|
| 350 |
+
yield f"data: {json.dumps(error_json)}\n\n".encode('utf-8')
|
| 351 |
+
return
|
| 352 |
+
|
| 353 |
+
# 处理SSE格式的chunk
|
| 354 |
+
if isinstance(chunk, (str, bytes)):
|
| 355 |
+
chunk_str = chunk.decode('utf-8') if isinstance(chunk, bytes) else chunk
|
| 356 |
+
|
| 357 |
+
# 解析并展开 response 包装
|
| 358 |
+
if chunk_str.startswith("data: "):
|
| 359 |
+
json_str = chunk_str[6:].strip()
|
| 360 |
+
|
| 361 |
+
# 跳过 [DONE] 标记
|
| 362 |
+
if json_str == "[DONE]":
|
| 363 |
+
yield chunk
|
| 364 |
+
continue
|
| 365 |
+
|
| 366 |
+
try:
|
| 367 |
+
# 解析JSON
|
| 368 |
+
data = json.loads(json_str)
|
| 369 |
+
|
| 370 |
+
# 展开 response 包装
|
| 371 |
+
if "response" in data and "candidates" not in data:
|
| 372 |
+
log.debug(f"[ANTIGRAVITY] 展开response包装")
|
| 373 |
+
unwrapped_data = data["response"]
|
| 374 |
+
# 重新构建SSE格式
|
| 375 |
+
yield f"data: {json.dumps(unwrapped_data, ensure_ascii=False)}\n\n".encode('utf-8')
|
| 376 |
+
else:
|
| 377 |
+
# 已经是展开的格式,直接返回
|
| 378 |
+
yield chunk
|
| 379 |
+
except json.JSONDecodeError:
|
| 380 |
+
# JSON解析失败,直接返回原始chunk
|
| 381 |
+
yield chunk
|
| 382 |
+
else:
|
| 383 |
+
# 不是SSE格式,直接返回
|
| 384 |
+
yield chunk
|
| 385 |
+
|
| 386 |
+
# ========== 根据模式选择生成器 ==========
|
| 387 |
+
if use_fake_streaming:
|
| 388 |
+
return StreamingResponse(fake_stream_generator(), media_type="text/event-stream")
|
| 389 |
+
elif use_anti_truncation:
|
| 390 |
+
log.info("启用流式抗截断功能")
|
| 391 |
+
return StreamingResponse(anti_truncation_generator(), media_type="text/event-stream")
|
| 392 |
+
else:
|
| 393 |
+
return StreamingResponse(normal_stream_generator(), media_type="text/event-stream")
|
| 394 |
+
|
| 395 |
+
@router.post("/antigravity/v1beta/models/{model:path}:countTokens")
|
| 396 |
+
@router.post("/antigravity/v1/models/{model:path}:countTokens")
|
| 397 |
+
async def count_tokens(
|
| 398 |
+
request: Request = None,
|
| 399 |
+
api_key: str = Depends(authenticate_gemini_flexible),
|
| 400 |
+
):
|
| 401 |
+
"""
|
| 402 |
+
模拟Gemini格式的token计数
|
| 403 |
+
|
| 404 |
+
使用简单的启发式方法:大约4字符=1token
|
| 405 |
+
"""
|
| 406 |
+
|
| 407 |
+
try:
|
| 408 |
+
request_data = await request.json()
|
| 409 |
+
except Exception as e:
|
| 410 |
+
log.error(f"Failed to parse JSON request: {e}")
|
| 411 |
+
raise HTTPException(status_code=400, detail=f"Invalid JSON: {str(e)}")
|
| 412 |
+
|
| 413 |
+
# 简单的token计数模拟 - 基于文本长度估算
|
| 414 |
+
total_tokens = 0
|
| 415 |
+
|
| 416 |
+
# 如果有contents字段
|
| 417 |
+
if "contents" in request_data:
|
| 418 |
+
for content in request_data["contents"]:
|
| 419 |
+
if "parts" in content:
|
| 420 |
+
for part in content["parts"]:
|
| 421 |
+
if "text" in part:
|
| 422 |
+
# 简单估算:大约4字符=1token
|
| 423 |
+
text_length = len(part["text"])
|
| 424 |
+
total_tokens += max(1, text_length // 4)
|
| 425 |
+
|
| 426 |
+
# 如果有generateContentRequest字段
|
| 427 |
+
elif "generateContentRequest" in request_data:
|
| 428 |
+
gen_request = request_data["generateContentRequest"]
|
| 429 |
+
if "contents" in gen_request:
|
| 430 |
+
for content in gen_request["contents"]:
|
| 431 |
+
if "parts" in content:
|
| 432 |
+
for part in content["parts"]:
|
| 433 |
+
if "text" in part:
|
| 434 |
+
text_length = len(part["text"])
|
| 435 |
+
total_tokens += max(1, text_length // 4)
|
| 436 |
+
|
| 437 |
+
# 返回Gemini格式的响应
|
| 438 |
+
return JSONResponse(content={"totalTokens": total_tokens})
|
| 439 |
+
|
| 440 |
+
# ==================== 测试代码 ====================
|
| 441 |
+
|
| 442 |
+
if __name__ == "__main__":
|
| 443 |
+
"""
|
| 444 |
+
测试代码:演示Gemini路由的流式和非流式响应
|
| 445 |
+
运行方式: python src/router/antigravity/gemini.py
|
| 446 |
+
"""
|
| 447 |
+
|
| 448 |
+
from fastapi.testclient import TestClient
|
| 449 |
+
from fastapi import FastAPI
|
| 450 |
+
|
| 451 |
+
print("=" * 80)
|
| 452 |
+
print("Gemini Router (Antigravity Backend) 测试")
|
| 453 |
+
print("=" * 80)
|
| 454 |
+
|
| 455 |
+
# 创建测试应用
|
| 456 |
+
app = FastAPI()
|
| 457 |
+
app.include_router(router)
|
| 458 |
+
|
| 459 |
+
# 测试客户端
|
| 460 |
+
client = TestClient(app)
|
| 461 |
+
|
| 462 |
+
# 测试请求体 (Gemini格式)
|
| 463 |
+
test_request_body = {
|
| 464 |
+
"contents": [
|
| 465 |
+
{
|
| 466 |
+
"role": "user",
|
| 467 |
+
"parts": [{"text": "Hello, tell me a joke in one sentence."}]
|
| 468 |
+
}
|
| 469 |
+
]
|
| 470 |
+
}
|
| 471 |
+
|
| 472 |
+
# 测试API密钥(模拟)
|
| 473 |
+
test_api_key = "pwd"
|
| 474 |
+
|
| 475 |
+
def test_non_stream_request():
|
| 476 |
+
"""测试非流式请求"""
|
| 477 |
+
print("\n" + "=" * 80)
|
| 478 |
+
print("【测试2】非流式请求 (POST /antigravity/v1/models/gemini-2.5-flash:generateContent)")
|
| 479 |
+
print("=" * 80)
|
| 480 |
+
print(f"请求体: {json.dumps(test_request_body, indent=2, ensure_ascii=False)}\n")
|
| 481 |
+
|
| 482 |
+
response = client.post(
|
| 483 |
+
"/antigravity/v1/models/gemini-2.5-flash:generateContent",
|
| 484 |
+
json=test_request_body,
|
| 485 |
+
params={"key": test_api_key}
|
| 486 |
+
)
|
| 487 |
+
|
| 488 |
+
print("非流式响应数据:")
|
| 489 |
+
print("-" * 80)
|
| 490 |
+
print(f"状态码: {response.status_code}")
|
| 491 |
+
print(f"Content-Type: {response.headers.get('content-type', 'N/A')}")
|
| 492 |
+
|
| 493 |
+
try:
|
| 494 |
+
content = response.text
|
| 495 |
+
print(f"\n响应内容 (原始):\n{content}\n")
|
| 496 |
+
|
| 497 |
+
# 尝试解析JSON
|
| 498 |
+
try:
|
| 499 |
+
json_data = response.json()
|
| 500 |
+
print(f"响应内容 (格式化JSON):")
|
| 501 |
+
print(json.dumps(json_data, indent=2, ensure_ascii=False))
|
| 502 |
+
except json.JSONDecodeError:
|
| 503 |
+
print("(非JSON格式)")
|
| 504 |
+
except Exception as e:
|
| 505 |
+
print(f"内容解析失败: {e}")
|
| 506 |
+
|
| 507 |
+
def test_stream_request():
|
| 508 |
+
"""测试流式请求"""
|
| 509 |
+
print("\n" + "=" * 80)
|
| 510 |
+
print("【测试3】流式请求 (POST /antigravity/v1/models/gemini-2.5-flash:streamGenerateContent)")
|
| 511 |
+
print("=" * 80)
|
| 512 |
+
print(f"请求体: {json.dumps(test_request_body, indent=2, ensure_ascii=False)}\n")
|
| 513 |
+
|
| 514 |
+
print("流式响应数据 (每个chunk):")
|
| 515 |
+
print("-" * 80)
|
| 516 |
+
|
| 517 |
+
with client.stream(
|
| 518 |
+
"POST",
|
| 519 |
+
"/antigravity/v1/models/gemini-2.5-flash:streamGenerateContent",
|
| 520 |
+
json=test_request_body,
|
| 521 |
+
params={"key": test_api_key}
|
| 522 |
+
) as response:
|
| 523 |
+
print(f"状态码: {response.status_code}")
|
| 524 |
+
print(f"Content-Type: {response.headers.get('content-type', 'N/A')}\n")
|
| 525 |
+
|
| 526 |
+
chunk_count = 0
|
| 527 |
+
for chunk in response.iter_bytes():
|
| 528 |
+
if chunk:
|
| 529 |
+
chunk_count += 1
|
| 530 |
+
print(f"\nChunk #{chunk_count}:")
|
| 531 |
+
print(f" 类型: {type(chunk).__name__}")
|
| 532 |
+
print(f" 长度: {len(chunk)}")
|
| 533 |
+
|
| 534 |
+
# 解码chunk
|
| 535 |
+
try:
|
| 536 |
+
chunk_str = chunk.decode('utf-8')
|
| 537 |
+
print(f" 内容预览: {repr(chunk_str[:200] if len(chunk_str) > 200 else chunk_str)}")
|
| 538 |
+
|
| 539 |
+
# 如果是SSE格式,尝试解析每一行
|
| 540 |
+
if chunk_str.startswith("data: "):
|
| 541 |
+
# 按行分割,处理每个SSE事件
|
| 542 |
+
for line in chunk_str.strip().split('\n'):
|
| 543 |
+
line = line.strip()
|
| 544 |
+
if not line:
|
| 545 |
+
continue
|
| 546 |
+
|
| 547 |
+
if line == "data: [DONE]":
|
| 548 |
+
print(f" => 流结束标记")
|
| 549 |
+
elif line.startswith("data: "):
|
| 550 |
+
try:
|
| 551 |
+
json_str = line[6:] # 去掉 "data: " 前缀
|
| 552 |
+
json_data = json.loads(json_str)
|
| 553 |
+
print(f" 解析后的JSON: {json.dumps(json_data, indent=4, ensure_ascii=False)}")
|
| 554 |
+
except Exception as e:
|
| 555 |
+
print(f" SSE解析失败: {e}")
|
| 556 |
+
except Exception as e:
|
| 557 |
+
print(f" 解码失败: {e}")
|
| 558 |
+
|
| 559 |
+
print(f"\n总共收到 {chunk_count} 个chunk")
|
| 560 |
+
|
| 561 |
+
def test_fake_stream_request():
|
| 562 |
+
"""测试假流式请求"""
|
| 563 |
+
print("\n" + "=" * 80)
|
| 564 |
+
print("【测试4】假流式请求 (POST /antigravity/v1/models/假流式/gemini-2.5-flash:streamGenerateContent)")
|
| 565 |
+
print("=" * 80)
|
| 566 |
+
print(f"请求体: {json.dumps(test_request_body, indent=2, ensure_ascii=False)}\n")
|
| 567 |
+
|
| 568 |
+
print("假流式响应数据 (每个chunk):")
|
| 569 |
+
print("-" * 80)
|
| 570 |
+
|
| 571 |
+
with client.stream(
|
| 572 |
+
"POST",
|
| 573 |
+
"/antigravity/v1/models/假流式/gemini-2.5-flash:streamGenerateContent",
|
| 574 |
+
json=test_request_body,
|
| 575 |
+
params={"key": test_api_key}
|
| 576 |
+
) as response:
|
| 577 |
+
print(f"状态码: {response.status_code}")
|
| 578 |
+
print(f"Content-Type: {response.headers.get('content-type', 'N/A')}\n")
|
| 579 |
+
|
| 580 |
+
chunk_count = 0
|
| 581 |
+
for chunk in response.iter_bytes():
|
| 582 |
+
if chunk:
|
| 583 |
+
chunk_count += 1
|
| 584 |
+
chunk_str = chunk.decode('utf-8')
|
| 585 |
+
|
| 586 |
+
print(f"\nChunk #{chunk_count}:")
|
| 587 |
+
print(f" 长度: {len(chunk_str)} 字节")
|
| 588 |
+
|
| 589 |
+
# 解析chunk中的所有SSE事件
|
| 590 |
+
events = []
|
| 591 |
+
for line in chunk_str.split('\n'):
|
| 592 |
+
line = line.strip()
|
| 593 |
+
if line.startswith("data: "):
|
| 594 |
+
events.append(line)
|
| 595 |
+
|
| 596 |
+
print(f" 包含 {len(events)} 个SSE事件")
|
| 597 |
+
|
| 598 |
+
# 显示每个事件
|
| 599 |
+
for event_idx, event_line in enumerate(events, 1):
|
| 600 |
+
if event_line == "data: [DONE]":
|
| 601 |
+
print(f" 事件 #{event_idx}: [DONE]")
|
| 602 |
+
else:
|
| 603 |
+
try:
|
| 604 |
+
json_str = event_line[6:] # 去掉 "data: " 前缀
|
| 605 |
+
json_data = json.loads(json_str)
|
| 606 |
+
# 提取text内容
|
| 607 |
+
text = json_data.get("candidates", [{}])[0].get("content", {}).get("parts", [{}])[0].get("text", "")
|
| 608 |
+
finish_reason = json_data.get("candidates", [{}])[0].get("finishReason")
|
| 609 |
+
print(f" 事件 #{event_idx}: text={repr(text[:50])}{'...' if len(text) > 50 else ''}, finishReason={finish_reason}")
|
| 610 |
+
except Exception as e:
|
| 611 |
+
print(f" 事件 #{event_idx}: 解析失败 - {e}")
|
| 612 |
+
|
| 613 |
+
print(f"\n总共收到 {chunk_count} 个HTTP chunk")
|
| 614 |
+
|
| 615 |
+
def test_anti_truncation_stream_request():
|
| 616 |
+
"""测试流式抗截断请求"""
|
| 617 |
+
print("\n" + "=" * 80)
|
| 618 |
+
print("【测试5】流式抗截断请求 (POST /antigravity/v1/models/流式抗截断/gemini-2.5-flash:streamGenerateContent)")
|
| 619 |
+
print("=" * 80)
|
| 620 |
+
print(f"请求体: {json.dumps(test_request_body, indent=2, ensure_ascii=False)}\n")
|
| 621 |
+
|
| 622 |
+
print("流式抗截断响应数据 (每个chunk):")
|
| 623 |
+
print("-" * 80)
|
| 624 |
+
|
| 625 |
+
with client.stream(
|
| 626 |
+
"POST",
|
| 627 |
+
"/antigravity/v1/models/流式抗截断/gemini-2.5-flash:streamGenerateContent",
|
| 628 |
+
json=test_request_body,
|
| 629 |
+
params={"key": test_api_key}
|
| 630 |
+
) as response:
|
| 631 |
+
print(f"状态码: {response.status_code}")
|
| 632 |
+
print(f"Content-Type: {response.headers.get('content-type', 'N/A')}\n")
|
| 633 |
+
|
| 634 |
+
chunk_count = 0
|
| 635 |
+
for chunk in response.iter_bytes():
|
| 636 |
+
if chunk:
|
| 637 |
+
chunk_count += 1
|
| 638 |
+
print(f"\nChunk #{chunk_count}:")
|
| 639 |
+
print(f" 类型: {type(chunk).__name__}")
|
| 640 |
+
print(f" 长度: {len(chunk)}")
|
| 641 |
+
|
| 642 |
+
# 解码chunk
|
| 643 |
+
try:
|
| 644 |
+
chunk_str = chunk.decode('utf-8')
|
| 645 |
+
print(f" 内容预览: {repr(chunk_str[:200] if len(chunk_str) > 200 else chunk_str)}")
|
| 646 |
+
|
| 647 |
+
# 如果是SSE格式,尝试解析每一行
|
| 648 |
+
if chunk_str.startswith("data: "):
|
| 649 |
+
# 按行分割,处理每个SSE事件
|
| 650 |
+
for line in chunk_str.strip().split('\n'):
|
| 651 |
+
line = line.strip()
|
| 652 |
+
if not line:
|
| 653 |
+
continue
|
| 654 |
+
|
| 655 |
+
if line == "data: [DONE]":
|
| 656 |
+
print(f" => 流结束标记")
|
| 657 |
+
elif line.startswith("data: "):
|
| 658 |
+
try:
|
| 659 |
+
json_str = line[6:] # 去掉 "data: " 前缀
|
| 660 |
+
json_data = json.loads(json_str)
|
| 661 |
+
print(f" 解析后的JSON: {json.dumps(json_data, indent=4, ensure_ascii=False)}")
|
| 662 |
+
except Exception as e:
|
| 663 |
+
print(f" SSE解析失败: {e}")
|
| 664 |
+
except Exception as e:
|
| 665 |
+
print(f" 解码失败: {e}")
|
| 666 |
+
|
| 667 |
+
print(f"\n总共收到 {chunk_count} 个chunk")
|
| 668 |
+
|
| 669 |
+
# 运行测试
|
| 670 |
+
try:
|
| 671 |
+
# 测试非流式请求
|
| 672 |
+
test_non_stream_request()
|
| 673 |
+
|
| 674 |
+
# 测试流式请求
|
| 675 |
+
test_stream_request()
|
| 676 |
+
|
| 677 |
+
# 测试假流式请求
|
| 678 |
+
test_fake_stream_request()
|
| 679 |
+
|
| 680 |
+
# 测试流式抗截断请求
|
| 681 |
+
test_anti_truncation_stream_request()
|
| 682 |
+
|
| 683 |
+
print("\n" + "=" * 80)
|
| 684 |
+
print("测试完成")
|
| 685 |
+
print("=" * 80)
|
| 686 |
+
|
| 687 |
+
except Exception as e:
|
| 688 |
+
print(f"\n❌ 测试过程中出现异常: {e}")
|
| 689 |
+
import traceback
|
| 690 |
+
traceback.print_exc()
|