lin7zhi commited on
Commit
69fec20
·
verified ·
1 Parent(s): b13ec20

Upload folder using huggingface_hub

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
.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: red
5
- colorTo: indigo
6
  sdk: docker
7
- pinned: false
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
1
  ---
2
+ title: "2api"
3
+ emoji: "🚀"
4
+ colorFrom: blue
5
+ colorTo: green
6
  sdk: docker
7
+ app_port: 7861
8
  ---
9
 
10
+ ### 🚀 一键部署
11
+ [![Deploy with HFSpaceDeploy](https://img.shields.io/badge/Deploy_with-HFSpaceDeploy-green?style=social&logo=rocket)](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
+ [![Python 3.12+](https://img.shields.io/badge/python-3.12+-blue.svg)](https://www.python.org/downloads/)
6
+ [![License: CNC-1.0](https://img.shields.io/badge/License-CNC--1.0-red.svg)](../LICENSE)
7
+ [![Docker](https://img.shields.io/badge/docker-available-blue.svg)](https://github.com/su-kaka/gcli2api/pkgs/container/gcli2api)
8
+
9
+ [中文](../README.md) | English
10
+
11
+ ## 🚀 Quick Deploy
12
+
13
+ [![Deploy on Zeabur](https://zeabur.com/button.svg)](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

  • SHA256: 58a7061b633748a639326e26636f93ef34d92b73b2c513743218f0f0d2c15247
  • Pointer size: 131 Bytes
  • Size of remote file: 193 kB
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"![Generated Image]({img['image_url']['url']})" 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()