a3216 commited on
Commit
96d34f2
·
0 Parent(s):

sync: github -> hf space

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
.env.example ADDED
@@ -0,0 +1,179 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ================================================================
2
+ # GCLI2API 环境变量配置示例文件
3
+ # 复制此文件为 .env 并根据需要修改配置值
4
+ # ================================================================
5
+
6
+ # ================================================================
7
+ # 服务器配置
8
+ # ================================================================
9
+
10
+ # 服务器监听地址
11
+ # 默认: 0.0.0.0 (监听所有网络接口)
12
+ HOST=0.0.0.0
13
+
14
+ # 服务器端口
15
+ # 默认: 7861
16
+ PORT=7861
17
+
18
+ # ================================================================
19
+ # 密码配置 (支持分离密码)
20
+ # ================================================================
21
+
22
+ # 聊天API访问密码 (用于OpenAI和Gemini API端点认证)
23
+ # 默认: 继承通用密码或 pwd
24
+ API_PASSWORD=your_api_password
25
+
26
+ # 控制面板访问密码 (用于Web界面登录认证)
27
+ # 默认: 继承通用密码或 pwd
28
+ PANEL_PASSWORD=your_panel_password
29
+
30
+ # 通用访问密码 (兼容性保留)
31
+ # 设置后会覆盖上述两个专用密码,优先级最高
32
+ # 如果只想使用一个密码,设置此项即可
33
+ # 默认: pwd
34
+ PASSWORD=pwd
35
+
36
+ # ================================================================
37
+ # 存储配置
38
+ # ================================================================
39
+
40
+ # 存储后端优先级: PostgreSQL > MongoDB > 本地sqlite文件存储
41
+ # 系统会自动选择可用的最高优先级存储后端
42
+
43
+ # PostgreSQL 分布式存储模式配置 (最高优先级)
44
+ # 设置 POSTGRESQL_URI 后自动启用 PostgreSQL 模式
45
+ # 本地 PostgreSQL: postgresql://user:password@localhost:5432/gcli2api
46
+ # 带 SSL: postgresql://user:password@host:5432/gcli2api?sslmode=require
47
+ # 默认: 无 (不启用 PostgreSQL 存储)
48
+ POSTGRESQL_URI=postgresql://user:password@localhost:5432/gcli2api
49
+
50
+ # MongoDB 分布式存储模式配置 (第二优先级)
51
+ # 设置 MONGODB_URI 后自动启用 MongoDB 模式,不再使用本地文件存储
52
+
53
+ # Redis 缓存存储配置
54
+ # 设置 REDIS_URL 后自动启用 Redis 模式,性能最佳,可大幅降低 MongoDB 的读写压力
55
+ # 本地 Redis: redis://127.0.0.1:6379/0
56
+ # 带密码: redis://:password@127.0.0.1:6379/0
57
+ # 默认: 无 (不启用 Redis 缓存)
58
+ REDIS_URL=redis://127.0.0.1:6379/0
59
+
60
+ # MongoDB 连接字符串 (设置后启用 MongoDB 分布式存储模式)
61
+ # 本地 MongoDB: mongodb://localhost:27017
62
+ # 带认证: mongodb://admin:password@localhost:27017/admin
63
+ # MongoDB Atlas: mongodb+srv://username:password@cluster.mongodb.net
64
+ # 副本集: mongodb://host1:27017,host2:27017,host3:27017/gcli2api?replicaSet=rs0
65
+ # 默认: 无 (使用本地文件存储)
66
+ MONGODB_URI=mongodb://localhost:27017
67
+
68
+ # MongoDB 数据库名称 (仅在启用 MongoDB 模式时有效)
69
+ # 默认: gcli2api
70
+ MONGODB_DATABASE=gcli2api
71
+
72
+ # ================================================================
73
+ # Google API 配置
74
+ # ================================================================
75
+
76
+ # 凭证文件目录 (仅在文件存储模式下使用)
77
+ # 默认: ./creds
78
+ CREDENTIALS_DIR=./creds
79
+
80
+
81
+ # 代理配置 (可选)
82
+ # 支持 http, https, socks5 代理
83
+ # 格式: http://proxy:port, https://proxy:port, socks5://proxy:port
84
+ PROXY=http://localhost:7890
85
+
86
+ # Google API 代理 URL 配置 (可选)
87
+
88
+ # Google Code Assist API 端点
89
+ # 默认: https://cloudcode-pa.googleapis.com
90
+ CODE_ASSIST_ENDPOINT=https://cloudcode-pa.googleapis.com
91
+ # 用于Google OAuth2认证的代理URL
92
+ # 默认: https://oauth2.googleapis.com
93
+ OAUTH_PROXY_URL=https://oauth2.googleapis.com
94
+
95
+ # 用于Google APIs调用的代理URL
96
+ # 默认: https://www.googleapis.com
97
+ GOOGLEAPIS_PROXY_URL=https://www.googleapis.com
98
+
99
+ # 用于Google Cloud Resource Manager API的URL
100
+ # 默认: https://cloudresourcemanager.googleapis.com
101
+ RESOURCE_MANAGER_API_URL=https://cloudresourcemanager.googleapis.com
102
+
103
+ # 用于Google Cloud Service Usage API的URL
104
+ # 默认: https://serviceusage.googleapis.com
105
+ SERVICE_USAGE_API_URL=https://serviceusage.googleapis.com
106
+
107
+ # 用于Google Antigravity API的URL (反重力模式)
108
+ # 默认: https://daily-cloudcode-pa.googleapis.com
109
+ ANTIGRAVITY_API_URL=https://daily-cloudcode-pa.googleapis.com
110
+
111
+ # ================================================================
112
+ # 错误处理和重试配置
113
+ # ================================================================
114
+
115
+ # 是否启用自动封禁功能
116
+ # 当凭证返回特定错误码时自动禁用该凭证
117
+ # 默认: false
118
+ AUTO_BAN=false
119
+
120
+ # 自动封禁的错误码列表 (逗号分隔)
121
+ # 默认: 400,403
122
+ AUTO_BAN_ERROR_CODES=403
123
+
124
+ # 是否启用 429 错误重试
125
+ # 默认: true
126
+ RETRY_429_ENABLED=true
127
+
128
+ # 429 错误最大重试次数
129
+ # 默认: 5
130
+ RETRY_429_MAX_RETRIES=5
131
+
132
+ # 429 错误重试间隔 (秒)
133
+ # 默认: 1
134
+ RETRY_429_INTERVAL=1
135
+
136
+ # ================================================================
137
+ # 日志配置
138
+ # ================================================================
139
+
140
+ # 日志级别
141
+ # 可选值: debug, info, warning, error, critical
142
+ # 默认: info
143
+ LOG_LEVEL=info
144
+
145
+ # 日志文件路径
146
+ # 默认: log.txt
147
+ LOG_FILE=log.txt
148
+
149
+ # ================================================================
150
+ # 高级功能配置
151
+ # ================================================================
152
+
153
+ # 流式抗截断最大尝试次数
154
+ # 用于 "流式抗截断/" 前缀的模型
155
+ # 默认: 3
156
+ ANTI_TRUNCATION_MAX_ATTEMPTS=3
157
+
158
+ # ================================================================
159
+ # 环境变量使用说明
160
+ # ================================================================
161
+
162
+ # 1. 存储模式配置 (按优先级自动选择):
163
+ # - PostgreSQL 分布式模式 (最高优先级): 设置 POSTGRESQL_URI,数据存储在 PostgreSQL 数据库
164
+ # - Redis 缓存: 同时设置 REDIS_URI和 MONGODB_URI时,数据缓存在 Redis 数据库,持久化在MONGODB,性能最佳
165
+ # - MongoDB 分布式模式: 设置 MONGODB_URI,数据存储在 MongoDB 数据库
166
+ # - 文件存储模式 (默认): 不设置上述 URI,数据存储在本地 creds/ 目录
167
+ # - 自动切换: 系统根据可用的存储配置自动选择最高优先级的存储后端
168
+
169
+ # 2. 密码配置优先级:
170
+ # a) PASSWORD 环境变量 (最高优先级,设置后覆盖其他密码)
171
+ # b) API_PASSWORD / PANEL_PASSWORD 环境变量 (专用密码)
172
+ # c) 默认值 "pwd"
173
+ #
174
+ # 3. 通用配置优先级:
175
+ # 环境变量 > 默认值
176
+
177
+ # 4. 布尔值环境变量:
178
+ # true/1/yes/on 表示启用
179
+ # false/0/no/off 表示禁用
.github/ISSUE_TEMPLATE/bug_report.yml ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Bug 报告
2
+ description: 报告项目使用中遇到的问题
3
+ title: "[Bug]: "
4
+ labels: ["bug", "待处理"]
5
+ body:
6
+ - type: markdown
7
+ attributes:
8
+ value: |
9
+ ## 感谢你的反馈!
10
+ 请填写以下信息以帮助我们更快定位问题。
11
+
12
+ - type: checkboxes
13
+ id: checklist
14
+ attributes:
15
+ label: 提交前确认
16
+ options:
17
+ - label: 我已经搜索过现有的 issues,确认这不是重复问题
18
+ required: true
19
+ - label: 我已经阅读过项目文档
20
+ required: true
21
+
22
+ - type: dropdown
23
+ id: latest-version
24
+ attributes:
25
+ label: 是否是最新版
26
+ description: 请确认你使用的是否是最新版本
27
+ options:
28
+ - 是,使用最新版
29
+ - 否,使用旧版本
30
+ validations:
31
+ required: true
32
+
33
+ - type: input
34
+ id: channel
35
+ attributes:
36
+ label: 调用的是哪个渠道
37
+ description: 例如 geminicli 或者 antigravity
38
+ placeholder: "例如: geminicli"
39
+ validations:
40
+ required: true
41
+
42
+ - type: input
43
+ id: model
44
+ attributes:
45
+ label: 调用的是哪个模型
46
+ description: 例如 gemini-2.5-flash
47
+ placeholder: "例如: gemini-2.5-flash"
48
+ validations:
49
+ required: true
50
+
51
+ - type: dropdown
52
+ id: format
53
+ attributes:
54
+ label: 调用的是哪个格式
55
+ description: 选择你使用的 API 格式
56
+ options:
57
+ - gemini 格式
58
+ - openai 格式
59
+ - claude 格式
60
+ - 其他格式
61
+ validations:
62
+ required: true
63
+
64
+ - type: textarea
65
+ id: error-content
66
+ attributes:
67
+ label: 具体报错内容
68
+ description: 请粘贴完整的错误信息或截图
69
+ placeholder: |
70
+ 请在这里粘贴完整的错误日志或堆栈信息
71
+ render: shell
72
+ validations:
73
+ required: true
74
+
75
+ - type: textarea
76
+ id: error-description
77
+ attributes:
78
+ label: 错误描述
79
+ description: 详细描述问题的发生场景、预期行为和实际行为
80
+ placeholder: |
81
+ 1. 我在做什么操作时遇到了这个问题
82
+ 2. 我期望的结果是...
83
+ 3. 但实际上发生了...
84
+ validations:
85
+ required: true
86
+
87
+ - type: textarea
88
+ id: additional-context
89
+ attributes:
90
+ label: 补充信息(可选)
91
+ description: 其他任何有助于解决问题的信息
92
+ placeholder: 例如:操作系统、Python 版本、相关配置等
.github/ISSUE_TEMPLATE/config.yml ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ blank_issues_enabled: false
2
+ contact_links:
3
+ - name: 使用问题讨论
4
+ url: https://github.com/su-kaka/gcli2api/issues
5
+ about: 如果是使用方面的问题,请在 issues 中提问
6
+ - name: 项目文档
7
+ url: https://github.com/su-kaka/gcli2api
8
+ about: 查看完整文档和使用指南
.github/workflows/docker-publish.yml ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Docker Build and Publish
2
+
3
+ on:
4
+ workflow_run:
5
+ workflows: ["Update Version File"]
6
+ types:
7
+ - completed
8
+ branches:
9
+ - master
10
+ - main
11
+ push:
12
+ tags:
13
+ - 'v*'
14
+ pull_request:
15
+ branches:
16
+ - master
17
+ - main
18
+ workflow_dispatch:
19
+
20
+ env:
21
+ REGISTRY: ghcr.io
22
+ IMAGE_NAME: ${{ github.repository }}
23
+
24
+ jobs:
25
+ build-and-push:
26
+ runs-on: ubuntu-latest
27
+ # 只在 workflow_run 成功时运行,或者非 workflow_run 触发时运行
28
+ if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }}
29
+ permissions:
30
+ contents: read
31
+ packages: write
32
+
33
+ steps:
34
+ - name: Checkout repository
35
+ uses: actions/checkout@v4
36
+ with:
37
+ # workflow_run 触发时需要获取最新的代码(包括 version.txt 的更新)
38
+ ref: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_branch || github.ref }}
39
+
40
+ - name: Set up QEMU
41
+ uses: docker/setup-qemu-action@v3
42
+
43
+ - name: Set up Docker Buildx
44
+ uses: docker/setup-buildx-action@v3
45
+
46
+ - name: Log in to GitHub Container Registry
47
+ uses: docker/login-action@v3
48
+ with:
49
+ registry: ${{ env.REGISTRY }}
50
+ username: ${{ github.actor }}
51
+ password: ${{ secrets.GITHUB_TOKEN }}
52
+
53
+ - name: Extract metadata
54
+ id: meta
55
+ uses: docker/metadata-action@v5
56
+ with:
57
+ images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
58
+ tags: |
59
+ type=ref,event=branch
60
+ type=ref,event=tag
61
+ type=ref,event=pr
62
+ type=raw,value=latest,enable={{is_default_branch}}
63
+ type=sha,prefix={{branch}}-
64
+ type=semver,pattern={{version}}
65
+ type=semver,pattern={{major}}.{{minor}}
66
+ type=semver,pattern={{major}}
67
+
68
+ - name: Build and push Docker image
69
+ uses: docker/build-push-action@v5
70
+ with:
71
+ context: .
72
+ platforms: linux/amd64,linux/arm64
73
+ push: ${{ github.event_name != 'pull_request' }}
74
+ tags: ${{ steps.meta.outputs.tags }}
75
+ labels: ${{ steps.meta.outputs.labels }}
76
+ cache-from: type=gha
77
+ cache-to: type=gha,mode=max
78
+ build-args: |
79
+ BUILD_DATE=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.created'] }}
80
+ VERSION=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.version'] }}
81
+ REVISION=${{ github.sha }}
.github/workflows/update-version.yml ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Update Version File
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - master
7
+ - main
8
+
9
+ jobs:
10
+ update-version:
11
+ runs-on: ubuntu-latest
12
+ permissions:
13
+ contents: write
14
+
15
+ steps:
16
+ - name: Checkout repository
17
+ uses: actions/checkout@v4
18
+ with:
19
+ fetch-depth: 0
20
+ token: ${{ secrets.GITHUB_TOKEN }}
21
+
22
+ - name: Update version.txt
23
+ run: |
24
+ # 获取最新commit信息
25
+ FULL_HASH=$(git log -1 --format=%H)
26
+ SHORT_HASH=$(git log -1 --format=%h)
27
+ MESSAGE=$(git log -1 --format=%s)
28
+ DATE=$(git log -1 --format=%ci)
29
+
30
+ # 写入version.txt
31
+ echo "full_hash=$FULL_HASH" > version.txt
32
+ echo "short_hash=$SHORT_HASH" >> version.txt
33
+ echo "message=$MESSAGE" >> version.txt
34
+ echo "date=$DATE" >> version.txt
35
+
36
+ echo "Version file updated:"
37
+ cat version.txt
38
+
39
+ - name: Commit version.txt if changed
40
+ run: |
41
+ git config --local user.email "github-actions[bot]@users.noreply.github.com"
42
+ git config --local user.name "github-actions[bot]"
43
+
44
+ # 检查是否有变化
45
+ if git diff --quiet version.txt; then
46
+ echo "No changes to version.txt"
47
+ else
48
+ git add version.txt
49
+ git commit -m "chore: update version.txt [skip ci]"
50
+ git push
51
+ fi
.gitignore ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Credential files - should never be committed
2
+ *.json
3
+ !package.json
4
+ !package-lock.json
5
+ !tsconfig.json
6
+ *.toml
7
+ !pyproject.toml
8
+ creds/
9
+ CLAUDE.md
10
+ GEMINI.md
11
+ .kiro
12
+ # Environment configuration
13
+ .env
14
+
15
+ # Python
16
+ uv.lock
17
+ .python-version
18
+ __pycache__/
19
+ *.py[cod]
20
+ *$py.class
21
+ *.so
22
+ .Python
23
+ build/
24
+ develop-eggs/
25
+ dist/
26
+ downloads/
27
+ eggs/
28
+ .eggs/
29
+ lib/
30
+ lib64/
31
+ parts/
32
+ sdist/
33
+ var/
34
+ wheels/
35
+ pip-wheel-metadata/
36
+ share/python-wheels/
37
+ *.egg-info/
38
+ .installed.cfg
39
+ *.egg
40
+ MANIFEST
41
+
42
+ # PyInstaller
43
+ *.manifest
44
+ *.spec
45
+
46
+ # Installer logs
47
+ pip-log.txt
48
+ pip-delete-this-directory.txt
49
+
50
+ # Unit test / coverage reports
51
+ htmlcov/
52
+ .tox/
53
+ .nox/
54
+ .coverage
55
+ .coverage.*
56
+ .cache
57
+ nosetests.xml
58
+ coverage.xml
59
+ *.cover
60
+ *.py,cover
61
+ .hypothesis/
62
+ .pytest_cache/
63
+
64
+ # Virtual environments
65
+ .env
66
+ .venv
67
+ env/
68
+ venv/
69
+ ENV/
70
+ env.bak/
71
+ venv.bak/
72
+
73
+ # IDE
74
+ .vscode/
75
+ .idea/
76
+ .claude/
77
+ *.swp
78
+ *.swo
79
+ *~
80
+
81
+ # OS
82
+ .DS_Store
83
+ .DS_Store?
84
+ ._*
85
+ .Spotlight-V100
86
+ .Trashes
87
+ ehthumbs.db
88
+ Thumbs.db
89
+
90
+ # Logs
91
+ *.log
92
+ log.txt
93
+
94
+ # Temporary files
95
+ *.tmp
96
+ *.temp
97
+ *.bak
98
+
99
+ tools/
CONTRIBUTING.md ADDED
@@ -0,0 +1,169 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Contributing to gcli2api
2
+
3
+ First off, thank you for considering contributing to gcli2api! It's people like you that make gcli2api such a great tool.
4
+
5
+ ## Code of Conduct
6
+
7
+ This project is intended for personal learning and research purposes only. By participating, you are expected to uphold this code and respect the CNC-1.0 license restrictions on commercial use.
8
+
9
+ ## How Can I Contribute?
10
+
11
+ ### Reporting Bugs
12
+
13
+ Before creating bug reports, please check the existing issues to avoid duplicates. When you create a bug report, include as many details as possible:
14
+
15
+ * **Use a clear and descriptive title**
16
+ * **Describe the exact steps to reproduce the problem**
17
+ * **Provide specific examples** - Include code snippets, configuration files, or log outputs
18
+ * **Describe the behavior you observed** and what you expected to see
19
+ * **Include environment details**: OS, Python version, Docker version (if applicable)
20
+
21
+ ### Suggesting Enhancements
22
+
23
+ Enhancement suggestions are tracked as GitHub issues. When creating an enhancement suggestion, include:
24
+
25
+ * **Use a clear and descriptive title**
26
+ * **Provide a detailed description** of the suggested enhancement
27
+ * **Explain why this enhancement would be useful**
28
+ * **List any alternative solutions** you've considered
29
+
30
+ ### Pull Requests
31
+
32
+ 1. Fork the repo and create your branch from `master`
33
+ 2. If you've added code that should be tested, add tests
34
+ 3. If you've changed APIs, update the documentation
35
+ 4. Ensure the test suite passes
36
+ 5. Make sure your code follows the existing style
37
+ 6. Write a clear commit message
38
+
39
+ ## Development Setup
40
+
41
+ ### Prerequisites
42
+
43
+ * Python 3.12 or higher
44
+ * pip or uv package manager
45
+
46
+ ### Setting Up Your Development Environment
47
+
48
+ ```bash
49
+ # Clone your fork
50
+ git clone https://github.com/YOUR_USERNAME/gcli2api.git
51
+ cd gcli2api
52
+
53
+ # Install development dependencies
54
+ make install-dev
55
+ # or
56
+ pip install -e ".[dev]"
57
+
58
+ # Copy environment example
59
+ cp .env.example .env
60
+ # Edit .env with your configuration
61
+ ```
62
+
63
+ ### Development Workflow
64
+
65
+ ```bash
66
+ # Run tests
67
+ make test
68
+
69
+ # Format code
70
+ make format
71
+
72
+ # Run linters
73
+ make lint
74
+
75
+ # Run the application locally
76
+ make run
77
+ ```
78
+
79
+ ### Testing
80
+
81
+ We use pytest for testing. All new features should include appropriate tests.
82
+
83
+ ```bash
84
+ # Run all tests
85
+ make test
86
+
87
+ # Run with coverage
88
+ make test-cov
89
+
90
+ # Run specific test file
91
+ python -m pytest test_tool_calling.py -v
92
+ ```
93
+
94
+ ### Code Style
95
+
96
+ * We use [Black](https://black.readthedocs.io/) for code formatting (line length: 100)
97
+ * We use [flake8](https://flake8.pycqa.org/) for linting
98
+ * We use [mypy](http://mypy-lang.org/) for type checking (optional, but encouraged)
99
+
100
+ ```bash
101
+ # Format your code before committing
102
+ make format
103
+
104
+ # Check if code is properly formatted
105
+ make format-check
106
+
107
+ # Run linters
108
+ make lint
109
+ ```
110
+
111
+ ## Project Structure
112
+
113
+ ```
114
+ gcli2api/
115
+ ├── src/ # Main source code
116
+ │ ├── auth.py # Authentication and OAuth
117
+ │ ├── credential_manager.py # Credential rotation
118
+ │ ├── openai_router.py # OpenAI-compatible endpoints
119
+ │ ├── gemini_router.py # Gemini native endpoints
120
+ │ ├── openai_transfer.py # Format conversion
121
+ │ ├── storage/ # Storage backends (Redis, MongoDB, Postgres, File)
122
+ │ └── ...
123
+ ├── front/ # Frontend static files
124
+ ├── tests/ # Test directory (to be created)
125
+ ├── test_*.py # Test files (root level)
126
+ ├── web.py # Main application entry point
127
+ ├── config.py # Configuration management
128
+ └── requirements.txt # Production dependencies
129
+ ```
130
+
131
+ ## Coding Guidelines
132
+
133
+ ### Python Style
134
+
135
+ * Follow PEP 8 guidelines
136
+ * Use type hints where appropriate
137
+ * Write docstrings for classes and functions
138
+ * Keep functions focused and concise
139
+
140
+ ### Commit Messages
141
+
142
+ * Use the present tense ("Add feature" not "Added feature")
143
+ * Use the imperative mood ("Move cursor to..." not "Moves cursor to...")
144
+ * Limit the first line to 72 characters or less
145
+ * Reference issues and pull requests liberally after the first line
146
+
147
+ ### Documentation
148
+
149
+ * Update the README.md if you change functionality
150
+ * Comment your code where necessary
151
+ * Update the .env.example if you add new configuration options
152
+
153
+ ## License
154
+
155
+ By contributing to gcli2api, you agree that your contributions will be licensed under the CNC-1.0 license. This is a strict anti-commercial license - see [LICENSE](LICENSE) for details.
156
+
157
+ ### Important License Restrictions
158
+
159
+ * ❌ No commercial use
160
+ * ❌ No use by companies with revenue > $1M USD
161
+ * ❌ No use by VC-backed or publicly traded companies
162
+ * ✅ Personal learning, research, and educational use only
163
+ * ✅ Open source integration (must follow same license)
164
+
165
+ ## Questions?
166
+
167
+ Feel free to open an issue with your question or reach out to the maintainers.
168
+
169
+ Thank you for contributing! 🎉
Dockerfile ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Multi-stage build for gcli2api
2
+ FROM python:3.13-slim as base
3
+
4
+ # Set environment variables
5
+ ENV PYTHONUNBUFFERED=1 \
6
+ PYTHONDONTWRITEBYTECODE=1 \
7
+ PIP_NO_CACHE_DIR=1 \
8
+ PIP_DISABLE_PIP_VERSION_CHECK=1 \
9
+ TZ=Asia/Shanghai
10
+
11
+ # Install tzdata and set timezone
12
+ RUN apt-get update && \
13
+ apt-get install -y --no-install-recommends tzdata && \
14
+ ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && \
15
+ echo "Asia/Shanghai" > /etc/timezone && \
16
+ apt-get clean && \
17
+ rm -rf /var/lib/apt/lists/*
18
+
19
+ WORKDIR /app
20
+
21
+ # Copy only requirements first for better caching
22
+ COPY requirements.txt .
23
+
24
+ # Install Python dependencies
25
+ RUN pip install --no-cache-dir -r requirements.txt
26
+
27
+ # Copy application code
28
+ COPY . .
29
+
30
+ # Expose port
31
+ EXPOSE 7861
32
+
33
+ # Default command
34
+ CMD ["python", "web.py"]
LICENSE ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Cooperative Non-Commercial License (CNC-1.0)
2
+
3
+ Copyright (c) 2024 gcli2api contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person or organization
6
+ obtaining a copy of this software and associated documentation files (the
7
+ "Software"), to use, copy, modify, merge, publish, distribute, and/or
8
+ sublicense the Software, subject to the following conditions:
9
+
10
+ TERMS AND CONDITIONS:
11
+
12
+ 1. NON-COMMERCIAL USE ONLY
13
+ The Software may only be used for non-commercial purposes. Commercial use
14
+ is strictly prohibited without explicit written permission from the
15
+ copyright holders.
16
+
17
+ 2. DEFINITION OF COMMERCIAL USE
18
+ "Commercial use" includes but is not limited to:
19
+ a) Using the Software to provide paid services or products
20
+ b) Integrating the Software into commercial products or services
21
+ c) Using the Software in any business operation that generates revenue
22
+ d) Offering the Software as part of a paid subscription or service
23
+ e) Using the Software to compete with the original project commercially
24
+
25
+ 3. COPYLEFT REQUIREMENT
26
+ Any derivative works, modifications, or substantial portions of the Software
27
+ must be licensed under the same or substantially similar terms. This ensures
28
+ that all derivatives remain non-commercial and freely available.
29
+
30
+ 4. SOURCE CODE AVAILABILITY
31
+ If you distribute the Software or any derivative works, you must make the
32
+ complete source code available under the same license terms at no charge.
33
+
34
+ 5. ATTRIBUTION REQUIREMENT
35
+ You must retain all copyright notices, license notices, and attribution
36
+ statements in all copies or substantial portions of the Software.
37
+
38
+ 6. ANTI-CORPORATE CLAUSE
39
+ This Software may not be used by corporations with annual revenue exceeding
40
+ $1 million USD, venture capital backed companies, or publicly traded
41
+ companies without explicit written permission from the copyright holders.
42
+
43
+ 7. EDUCATIONAL AND RESEARCH EXEMPTION
44
+ Use by educational institutions, non-profit research organizations, and
45
+ individual researchers for educational or research purposes is explicitly
46
+ permitted and encouraged.
47
+
48
+ 8. MODIFICATION AND CONTRIBUTION
49
+ Modifications and contributions to the Software are welcomed and encouraged,
50
+ provided they comply with these license terms. Contributors grant the same
51
+ license to their contributions.
52
+
53
+ 9. PATENT GRANT
54
+ Each contributor grants you a non-exclusive, worldwide, royalty-free patent
55
+ license to make, have made, use, offer to sell, sell, import, and otherwise
56
+ transfer the Work for non-commercial purposes only.
57
+
58
+ 10. TERMINATION
59
+ This license automatically terminates if you violate any of its terms.
60
+ Upon termination, you must cease all use and distribution of the Software
61
+ and destroy all copies in your possession.
62
+
63
+ 11. LIABILITY DISCLAIMER
64
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
65
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
66
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
67
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
68
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
69
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
70
+ SOFTWARE.
71
+
72
+ 12. JURISDICTION
73
+ This license shall be governed by and construed in accordance with the laws
74
+ of the jurisdiction where the copyright holder resides.
75
+
76
+ SUMMARY:
77
+ This license allows free use, modification, and distribution of the Software
78
+ for non-commercial purposes only. It explicitly prohibits commercial use and
79
+ ensures that all derivatives remain freely available under the same terms.
80
+ The license promotes cooperative development while preventing commercial
81
+ exploitation of the community's work.
82
+
83
+ For commercial licensing inquiries, please contact the copyright holders.
README.md ADDED
@@ -0,0 +1,891 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: gcli2api
3
+ colorFrom: blue
4
+ colorTo: green
5
+ sdk: docker
6
+ app_port: 7861
7
+ pinned: false
8
+ ---
9
+ # GeminiCLI to API
10
+
11
+ **将 GeminiCLI 和 Antigravity 转换为 OpenAI 、GEMINI 和 Claude API 兼容接口**
12
+
13
+ [![Python 3.12+](https://img.shields.io/badge/python-3.12+-blue.svg)](https://www.python.org/downloads/)
14
+ [![License: CNC-1.0](https://img.shields.io/badge/License-CNC--1.0-red.svg)](LICENSE)
15
+ [![Docker](https://img.shields.io/badge/docker-available-blue.svg)](https://github.com/su-kaka/gcli2api/pkgs/container/gcli2api)
16
+
17
+ [English](docs/README_EN.md) | 中文 | [日本語](docs/README_JA.md)
18
+
19
+ ## 🚀 快速部署
20
+
21
+ [![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/templates/97VMEF?referralCode=sukaka)
22
+ [![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo=https://github.com/su-kaka/gcli2api)
23
+ ---
24
+
25
+ ## 安装指南
26
+
27
+ ### Termux 环境
28
+
29
+ **初始安装**
30
+ ```bash
31
+ 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
32
+ ```
33
+
34
+ **重启服务**
35
+ ```bash
36
+ cd gcli2api
37
+ bash termux-start.sh
38
+ ```
39
+
40
+ ### Windows 环境
41
+
42
+ **初始安装**
43
+ ```powershell
44
+ iex (iwr "https://raw.githubusercontent.com/su-kaka/gcli2api/refs/heads/master/install.ps1" -UseBasicParsing).Content
45
+ ```
46
+
47
+ **重启服务**
48
+ 双击执行 `start.bat`
49
+
50
+ ### Linux 环境
51
+
52
+ **初始安装**
53
+ ```bash
54
+ curl -o install.sh "https://raw.githubusercontent.com/su-kaka/gcli2api/refs/heads/master/install.sh" && chmod +x install.sh && ./install.sh
55
+ ```
56
+
57
+ **重启服务**
58
+ ```bash
59
+ cd gcli2api
60
+ bash start.sh
61
+ ```
62
+
63
+ ### macOS 环境
64
+
65
+ **初始安装**
66
+ ```bash
67
+ curl -o darwin-install.sh "https://raw.githubusercontent.com/su-kaka/gcli2api/refs/heads/master/darwin-install.sh" && chmod +x darwin-install.sh && ./darwin-install.sh
68
+ ```
69
+
70
+ **重启服务**
71
+ ```bash
72
+ cd gcli2api
73
+ bash start.sh
74
+ ```
75
+
76
+ ### Docker 环境
77
+
78
+ **Docker 运行命令**
79
+ ```bash
80
+ # 使用通用密码
81
+ 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
82
+
83
+ # 使用分离密码
84
+ 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
85
+ ```
86
+
87
+ **Docker Mac**
88
+ ```bash
89
+ # 使用通用密码
90
+ docker run -d \
91
+ --name gcli2api \
92
+ -p 7861:7861 \
93
+ -p 8080:8080 \
94
+ -e PASSWORD=pwd \
95
+ -e PORT=7861 \
96
+ -v "$(pwd)/data/creds":/app/creds \
97
+ ghcr.io/su-kaka/gcli2api:latest
98
+ ```
99
+
100
+ ```bash
101
+ # 使用分离密码
102
+ docker run -d \
103
+ --name gcli2api \
104
+ -p 7861:7861 \
105
+ -p 8080:8080 \
106
+ -e API_PASSWORD=api_pwd \
107
+ -e PANEL_PASSWORD=panel_pwd \
108
+ -e PORT=7861 \
109
+ -v $(pwd)/data/creds:/app/creds \
110
+ ghcr.io/su-kaka/gcli2api:latest
111
+ ```
112
+
113
+ **Docker Compose 运行命令**
114
+ 1. 将以下内容保存为 `docker-compose.yml` 文件:
115
+ ```yaml
116
+ version: '3.8'
117
+
118
+ services:
119
+ gcli2api:
120
+ image: ghcr.io/su-kaka/gcli2api:latest
121
+ container_name: gcli2api
122
+ restart: unless-stopped
123
+ network_mode: host
124
+ environment:
125
+ # 使用通用密码(推荐用于简单部署)
126
+ - PASSWORD=pwd
127
+ - PORT=7861
128
+ # 或使用分离密码(推荐用于生产环境)
129
+ # - API_PASSWORD=your_api_password
130
+ # - PANEL_PASSWORD=your_panel_password
131
+ volumes:
132
+ - ./data/creds:/app/creds
133
+ healthcheck:
134
+ 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)\""]
135
+ interval: 30s
136
+ timeout: 10s
137
+ retries: 3
138
+ start_period: 40s
139
+ ```
140
+ 2. 启动服务:
141
+ ```bash
142
+ docker-compose up -d
143
+ ```
144
+
145
+ ## 核心功能
146
+
147
+ ### 🔄 API 端点和格式支持
148
+
149
+ **多端点多格式支持**
150
+ - **OpenAI 兼容端点**:`/v1/chat/completions` 和 `/v1/models`
151
+ - 支持标准 OpenAI 格式(messages 结构)
152
+ - 支持 Gemini 原生格式(contents 结构)
153
+ - 自动格式检测和转换,无需手动切换
154
+ - 支持多模态输入(文本 + 图像)
155
+ - **Gemini 原生端点**:`/v1/models/{model}:generateContent` 和 `streamGenerateContent`
156
+ - 支持完整的 Gemini 原生 API 规范
157
+ - 多种认证方式:Bearer Token、x-goog-api-key 头部、URL 参数 key
158
+ - **Claude 格式兼容**:完整支持 Claude API 格式
159
+ - 端点:`/v1/messages`(遵循 Claude API 规范)
160
+ - 支持 Claude 标准的 messages 格式
161
+ - 支持 system 参数和 Claude 特有功能
162
+ - 自动转换为后端支持的格式
163
+ - **Antigravity API 支持**:同时支持 OpenAI、Gemini 和 Claude 格式
164
+ - OpenAI 格式端点:`/antigravity/v1/chat/completions`
165
+ - Gemini 格式端点:`/antigravity/v1/models/{model}:generateContent` 和 `streamGenerateContent`
166
+ - Claude 格式端点:`/antigravity/v1/messages`
167
+ - 支持所有 Antigravity 模型(Claude、Gemini 等)
168
+ - 自动模型名称映射和思维模式检测
169
+
170
+ ### 🔐 认证和安全管理
171
+
172
+ **灵活的密码管理**
173
+ - **分离密码支持**:API 密码(聊天端点)和控制面板密码可独立设置
174
+ - **多种认证方式**:支持 Authorization Bearer、x-goog-api-key 头部、URL 参数等
175
+ - **JWT Token 认证**:控制面板支持 JWT 令牌认证
176
+ - **用户邮箱获取**:自动获取和显示 Google 账户邮箱地址
177
+
178
+ ### 📊 智能凭证管理系统
179
+
180
+ **高级凭证管理**
181
+ - 多个 Google OAuth 凭证自动轮换
182
+ - 通过冗余认证增强稳定性
183
+ - 负载均衡与并发请求支持
184
+ - 自动故障检测和凭证禁用
185
+ - 凭证使用统计和配额管理
186
+ - 支持手动启用/禁用凭证文件
187
+ - 批量凭证文件操作(启用、禁用、删除)
188
+
189
+ **凭证状态监控**
190
+ - 实时凭证健康检查
191
+ - 错误码追踪(429、403、500 等)
192
+ - 自动封禁机制(可配置)
193
+
194
+ ### 🌊 流式传输和响应处理
195
+
196
+ **多种流式支持**
197
+ - 真正的实时流式响应
198
+ - 假流式模式(用于兼容性)
199
+ - 流式抗截断功能(防止回答被截断)
200
+ - 异步任务管理和超时处理
201
+
202
+ **响应优化**
203
+ - 思维链(Thinking)内容分离
204
+ - 推理过程(reasoning_content)处理
205
+ - 多轮对话上下文管理
206
+ - 兼容性模式(将 system 消息转换为 user 消息)
207
+
208
+ ### 🎛️ Web 管理控制台
209
+
210
+ **全功能 Web 界面**
211
+ - OAuth 认证流程管理(支持 GCLI 和 Antigravity 双模式)
212
+ - 凭证文件上传、下载、管理
213
+ - 实时日志查看(WebSocket)
214
+ - 系统配置管理
215
+ - 使用统计和监控面板
216
+ - 移动端适配界面
217
+
218
+ **批量操作支持**
219
+ - ZIP 文件批量上传凭证(GCLI 和 Antigravity)
220
+ - 批量启用/禁用/删除凭证
221
+ - 批量获取用户邮箱
222
+ - 批量配置管理
223
+ - 统一批量上传界面管理所有凭证类型
224
+
225
+ ### 📈 使用监控
226
+
227
+ **实时监控**
228
+ - WebSocket 实时日志流
229
+ - 系统状态监控
230
+ - 凭证健康状态
231
+
232
+ ### 🔧 高级配置和自定义
233
+
234
+ **网络和代理配置**
235
+ - HTTP/HTTPS 代理支持
236
+ - 代理端点配置(OAuth、Google APIs、元数据服务)
237
+ - 超时和重试配置
238
+ - 网络错误处理和恢复
239
+
240
+ **性能和稳定性配置**
241
+ - 429 错误自动重试(可配置间隔和次数)
242
+ - 抗截断最大重试次数
243
+
244
+ **日志和调试**
245
+ - 多级日志系统(DEBUG、INFO、WARNING、ERROR)
246
+ - 日志文件管理
247
+ - 实时日志流
248
+ - 日志下载和清空
249
+
250
+ ### 🔄 环境变量和配置管理
251
+
252
+ **灵活的配置方式**
253
+ - 环境变量配置
254
+ - 热配置更新(部分配置项)
255
+ - 配置锁定(环境变量优先级)
256
+
257
+ ## 支持的模型
258
+
259
+ 所有模型均具备 1M 上下文窗口容量。每个凭证文件提供 1000 次请求额度。
260
+
261
+ ### 🤖 基础模型
262
+ - `gemini-2.5-pro`
263
+ - `gemini-3-pro-preview`
264
+ - `gemini-3.1-pro-preview`
265
+
266
+ ### 🧠 思维模型(Thinking Models)
267
+ - `gemini-2.5-pro-high`:思考模式
268
+ - `gemini-2.5-pro-low`:低思考模式
269
+ - 支持自定义思考预算配置
270
+ - 自动分离思维内容和最终回答
271
+
272
+ ### 🔍 搜索增强模型
273
+ - `gemini-2.5-pro-search`:集成搜索功能的模型
274
+
275
+ ### 🖼️ 图像生成模型(Antigravity)
276
+ - `gemini-3.1-flash-image`:基础图像生成模型
277
+ - **分辨率后缀**:
278
+ - `-2k`:2K 分辨率
279
+ - `-4k`:4K 高清分辨率
280
+ - **比例后缀**:
281
+ - `-1x1`:正方形(头像)
282
+ - `-16x9`:横屏(电脑壁纸)
283
+ - `-9x16`:竖屏(手机壁纸)
284
+ - `-21x9`:超宽屏(带鱼屏)
285
+ - `-4x3`:传统显示器
286
+ - `-3x4`:竖版海报
287
+ - **组合使用示例**:
288
+ - `gemini-3.1-flash-image-4k-16x9`:4K 横屏
289
+ - `gemini-3.1-flash-image-2k-9x16`:2K 竖屏
290
+ - 不指定比例时,API 自动决定横竖比例
291
+
292
+ ### 🌊 特殊功能变体
293
+ - **假流式模式**:在任何模型名称后添加 `-假流式` 后缀
294
+ - 例:`gemini-2.5-pro-假流式`
295
+ - 用于需要流式响应但服务端不支持真流式的场景
296
+ - **流式抗截断模式**:在模型名称前添加 `流式抗截断/` 前缀
297
+ - 例:`流式抗截断/gemini-2.5-pro`
298
+ - 自动检测响应截断并重试,确保完整回答
299
+
300
+ ### 🔧 模型功能自动检测
301
+ - 系统自动识别模型名称中的功能标识
302
+ - 透明地处理功能模式转换
303
+ - 支持功能组合使用
304
+
305
+ ---
306
+
307
+ ## 配置说明
308
+
309
+ 1. 访问 `http://127.0.0.1:7861` (默认端口,可通过 PORT 环境变量修改)
310
+ 2. 完成 OAuth 认证流程(默认密码:`pwd`,可通过环境变量修改)
311
+ - **GCLI 模式**:用于获取 Google Cloud Gemini API 凭证
312
+ - **Antigravity 模式**:用于获取 Google Antigravity API 凭证
313
+ 3. 配置客户端:
314
+
315
+ **OpenAI 兼容客户端:**
316
+ - **端点地址**:`http://127.0.0.1:7861/v1`
317
+ - **API 密钥**:`pwd`(默认值,可通过 API_PASSWORD 或 PASSWORD 环境变量修改)
318
+
319
+ **Gemini 原生客户端:**
320
+ - **端点地址**:`http://127.0.0.1:7861`
321
+ - **认证方式**:
322
+ - `Authorization: Bearer your_api_password`
323
+ - `x-goog-api-key: your_api_password`
324
+ - URL 参数:`?key=your_api_password`
325
+
326
+ ### 🌟 双认证模式支持
327
+
328
+ **GCLI 认证模式**
329
+ - 标准的 Google Cloud Gemini API 认证
330
+ - 支持 OAuth2.0 认证流程
331
+ - 自动启用必需的 Google Cloud API
332
+
333
+ **Antigravity 认证模式**
334
+ - Google Antigravity API 专用认证
335
+ - 独立的凭证管理系统
336
+ - 支持批量上传和管理
337
+ - 与 GCLI 凭证完全隔离
338
+
339
+ **统一管理界面**
340
+ - 在"批量上传"标签页中可一次性管理两种凭证
341
+ - 上半部分:GCLI 凭证批量上传(蓝色主题)
342
+ - 下半部分:Antigravity 凭证批量上传(绿色主题)
343
+ - 各自独立的凭证管理标签页
344
+
345
+ ## 💾 数据存储模式
346
+
347
+ ### 🌟 存储后端支持
348
+
349
+ gcli2api 支持两种存储后端:**本地 SQLite(默认)** 和 **MongoDB(云端分布式存储)**
350
+
351
+ ### 📁 本地 SQLite 存储(默认)
352
+
353
+ **默认存储方式**
354
+ - 无需配置,开箱即用
355
+ - 数据存储在本地 SQLite 数据库中
356
+ - 适合单机部署和个人使用
357
+ - 自动创建和管理数据库文件
358
+
359
+ ### 🍃 MongoDB 云端存储模式
360
+
361
+ **云端分布式存储方案**
362
+
363
+ 当需要多实例部署或云端存储时,可以启用 MongoDB 存储模式。
364
+
365
+ ### ⚙️ 启用 MongoDB 模式
366
+
367
+ **步骤 1: 配置 MongoDB 连接**
368
+ ```bash
369
+ # 本地 MongoDB
370
+ export MONGODB_URI="mongodb://localhost:27017"
371
+
372
+ # MongoDB Atlas 云服务
373
+ export MONGODB_URI="mongodb+srv://username:password@cluster.mongodb.net"
374
+
375
+ # 带认证的 MongoDB
376
+ export MONGODB_URI="mongodb://admin:password@localhost:27017/admin"
377
+
378
+ # 可选:自定义数据库名称(默认: gcli2api)
379
+ export MONGODB_DATABASE="my_gcli_db"
380
+ ```
381
+
382
+ **步骤 2: 启动应用**
383
+ ```bash
384
+ # 应用会自动检测 MongoDB 配置并使用 MongoDB 存储
385
+ python web.py
386
+ ```
387
+
388
+ **Docker 环境使用 MongoDB**
389
+ ```bash
390
+ # 单机 MongoDB 部署
391
+ docker run -d --name gcli2api \
392
+ -e MONGODB_URI="mongodb://mongodb:27017" \
393
+ -e API_PASSWORD=your_password \
394
+ --network your_network \
395
+ ghcr.io/su-kaka/gcli2api:latest
396
+
397
+ # 使用 MongoDB Atlas
398
+ docker run -d --name gcli2api \
399
+ -e MONGODB_URI="mongodb+srv://user:pass@cluster.mongodb.net/gcli2api" \
400
+ -e API_PASSWORD=your_password \
401
+ -p 7861:7861 \
402
+ ghcr.io/su-kaka/gcli2api:latest
403
+ ```
404
+
405
+ **Docker Compose 示例**
406
+ ```yaml
407
+ version: '3.8'
408
+
409
+ services:
410
+ mongodb:
411
+ image: mongo:7
412
+ container_name: gcli2api-mongodb
413
+ restart: unless-stopped
414
+ environment:
415
+ MONGO_INITDB_ROOT_USERNAME: admin
416
+ MONGO_INITDB_ROOT_PASSWORD: password123
417
+ volumes:
418
+ - mongodb_data:/data/db
419
+ ports:
420
+ - "27017:27017"
421
+
422
+ gcli2api:
423
+ image: ghcr.io/su-kaka/gcli2api:latest
424
+ container_name: gcli2api
425
+ restart: unless-stopped
426
+ depends_on:
427
+ - mongodb
428
+ environment:
429
+ - MONGODB_URI=mongodb://admin:password123@mongodb:27017/admin
430
+ - MONGODB_DATABASE=gcli2api
431
+ - API_PASSWORD=your_api_password
432
+ - PORT=7861
433
+ ports:
434
+ - "7861:7861"
435
+
436
+ volumes:
437
+ mongodb_data:
438
+ ```
439
+
440
+
441
+ ### 🔧 高级配置
442
+
443
+ **MongoDB 连接优化**
444
+ ```bash
445
+ # 连接池和超时配置
446
+ export MONGODB_URI="mongodb://localhost:27017?maxPoolSize=10&serverSelectionTimeoutMS=5000"
447
+
448
+ # 副本集配置
449
+ export MONGODB_URI="mongodb://host1:27017,host2:27017,host3:27017/gcli2api?replicaSet=myReplicaSet"
450
+
451
+ # 读写分离配置
452
+ export MONGODB_URI="mongodb://localhost:27017/gcli2api?readPreference=secondaryPreferred"
453
+ ```
454
+
455
+ ### 环境变量配置
456
+
457
+ **基础配置**
458
+ - `PORT`: 服务端口(默认:7861)
459
+ - `HOST`: 服务器监听地址(默认:0.0.0.0)
460
+
461
+ **密码配置**
462
+ - `API_PASSWORD`: 聊天 API 访问密码(默认:继承 PASSWORD 或 pwd)
463
+ - `PANEL_PASSWORD`: 控制面板访问密码(默认:继承 PASSWORD 或 pwd)
464
+ - `PASSWORD`: 通用密码,设置后覆盖上述两个(默认:pwd)
465
+
466
+ **性能和稳定性配置**
467
+ - `RETRY_429_ENABLED`: 启用 429 错误自动重试(默认:true)
468
+ - `RETRY_429_MAX_RETRIES`: 429 错误最大重试次数(默认:3)
469
+ - `RETRY_429_INTERVAL`: 429 错误重试间隔,秒(默认:1.0)
470
+ - `ANTI_TRUNCATION_MAX_ATTEMPTS`: 抗截断最大重试次数(默认:3)
471
+
472
+ **网络和代理配置**
473
+ - `PROXY`: HTTP/HTTPS 代理地址(格式:`http://host:port`)
474
+ - `OAUTH_PROXY_URL`: OAuth 认证代理端点
475
+ - `GOOGLEAPIS_PROXY_URL`: Google APIs 代理端点
476
+ - `METADATA_SERVICE_URL`: 元数据服务代理端点
477
+
478
+ **自动化配置**
479
+ - `AUTO_BAN`: 启用凭证自动封禁(默认:true)
480
+ - `AUTO_LOAD_ENV_CREDS`: 启动时自动加载环境变量凭证(默认:false)
481
+
482
+ **兼容性配置**
483
+ - `COMPATIBILITY_MODE`: 启用兼容性模式,将 system 消息转为 user 消息(默认:false)
484
+
485
+ **日志配置**
486
+ - `LOG_LEVEL`: 日志级别(DEBUG/INFO/WARNING/ERROR,默认:INFO)
487
+ - `LOG_FILE`: 日志文件路径(默认:log.txt)
488
+
489
+ **存储配置**
490
+
491
+ **SQLite 配置(默认)**
492
+ - 无需配置,自动使用本地 SQLite 数据库
493
+ - 数据���文件自动创建在项目目录
494
+
495
+ **MongoDB 配置(可选云端存储)**
496
+ - `MONGODB_URI`: MongoDB 连接字符串(设置后启用 MongoDB 模式)
497
+ - `MONGODB_DATABASE`: MongoDB 数据库名称(默认:gcli2api)
498
+
499
+ **Docker 使用示例**
500
+ ```bash
501
+ # 使用通用密码
502
+ docker run -d --name gcli2api \
503
+ -e PASSWORD=mypassword \
504
+ -e PORT=7861 \
505
+ ghcr.io/su-kaka/gcli2api:latest
506
+
507
+ # 使用分离密码
508
+ docker run -d --name gcli2api \
509
+ -e API_PASSWORD=my_api_password \
510
+ -e PANEL_PASSWORD=my_panel_password \
511
+ -e PORT=7861 \
512
+ ghcr.io/su-kaka/gcli2api:latest
513
+ ```
514
+
515
+ 注意:当设置了凭证环境变量时,系统将优先使用环境变量中的凭证,忽略 `creds` 目录中的文件。
516
+
517
+ ### API 使用方式
518
+
519
+ 本服务支持三套完整的 API 端点:
520
+
521
+ #### 1. OpenAI 兼容端点(GCLI)
522
+
523
+ **端点:** `/v1/chat/completions`
524
+ **认证:** `Authorization: Bearer your_api_password`
525
+
526
+ 支持两种请求格式,会自动检测并处理:
527
+
528
+ **OpenAI 格式:**
529
+ ```json
530
+ {
531
+ "model": "gemini-2.5-pro",
532
+ "messages": [
533
+ {"role": "system", "content": "You are a helpful assistant"},
534
+ {"role": "user", "content": "Hello"}
535
+ ],
536
+ "temperature": 0.7,
537
+ "stream": true
538
+ }
539
+ ```
540
+
541
+ **Gemini 原生格式:**
542
+ ```json
543
+ {
544
+ "model": "gemini-2.5-pro",
545
+ "contents": [
546
+ {"role": "user", "parts": [{"text": "Hello"}]}
547
+ ],
548
+ "systemInstruction": {"parts": [{"text": "You are a helpful assistant"}]},
549
+ "generationConfig": {
550
+ "temperature": 0.7
551
+ }
552
+ }
553
+ ```
554
+
555
+ #### 2. Gemini 原生端点(GCLI)
556
+
557
+ **非流式端点:** `/v1/models/{model}:generateContent`
558
+ **流式端点:** `/v1/models/{model}:streamGenerateContent`
559
+ **模型列表:** `/v1/models`
560
+
561
+ **认证方式(任选一种):**
562
+ - `Authorization: Bearer your_api_password`
563
+ - `x-goog-api-key: your_api_password`
564
+ - URL 参数:`?key=your_api_password`
565
+
566
+ **请求示例:**
567
+ ```bash
568
+ # 使用 x-goog-api-key 头部
569
+ curl -X POST "http://127.0.0.1:7861/v1/models/gemini-2.5-pro:generateContent" \
570
+ -H "x-goog-api-key: your_api_password" \
571
+ -H "Content-Type: application/json" \
572
+ -d '{
573
+ "contents": [
574
+ {"role": "user", "parts": [{"text": "Hello"}]}
575
+ ]
576
+ }'
577
+
578
+ # 使用 URL 参数
579
+ curl -X POST "http://127.0.0.1:7861/v1/models/gemini-2.5-pro:streamGenerateContent?key=your_api_password" \
580
+ -H "Content-Type: application/json" \
581
+ -d '{
582
+ "contents": [
583
+ {"role": "user", "parts": [{"text": "Hello"}]}
584
+ ]
585
+ }'
586
+ ```
587
+
588
+ #### 3. Claude API 格式端点
589
+
590
+ **端点:** `/v1/messages`
591
+ **认证:** `x-api-key: your_api_password` 或 `Authorization: Bearer your_api_password`
592
+
593
+ **请求示例:**
594
+ ```bash
595
+ curl -X POST "http://127.0.0.1:7861/v1/messages" \
596
+ -H "x-api-key: your_api_password" \
597
+ -H "anthropic-version: 2023-06-01" \
598
+ -H "Content-Type: application/json" \
599
+ -d '{
600
+ "model": "gemini-2.5-pro",
601
+ "max_tokens": 1024,
602
+ "messages": [
603
+ {"role": "user", "content": "Hello, Claude!"}
604
+ ]
605
+ }'
606
+ ```
607
+
608
+ **支持 system 参数:**
609
+ ```json
610
+ {
611
+ "model": "gemini-2.5-pro",
612
+ "max_tokens": 1024,
613
+ "system": "You are a helpful assistant",
614
+ "messages": [
615
+ {"role": "user", "content": "Hello"}
616
+ ]
617
+ }
618
+ ```
619
+
620
+ **说明:**
621
+ - 完全兼容 Claude API 格式规范
622
+ - 自动转换为 Gemini 格式调用后端
623
+ - 支持 Claude 的所有标准参数
624
+ - 响应格式符合 Claude API 规范
625
+
626
+ #### 4. Antigravity API 端点
627
+
628
+ **支持三种格式:OpenAI、Gemini 和 Claude**
629
+
630
+ ##### Antigravity OpenAI 格式端点
631
+
632
+ **端点:** `/antigravity/v1/chat/completions`
633
+ **认证:** `Authorization: Bearer your_api_password`
634
+
635
+ **请求示例:**
636
+ ```bash
637
+ curl -X POST "http://127.0.0.1:7861/antigravity/v1/chat/completions" \
638
+ -H "Authorization: Bearer your_api_password" \
639
+ -H "Content-Type: application/json" \
640
+ -d '{
641
+ "model": "claude-sonnet-4-5",
642
+ "messages": [
643
+ {"role": "user", "content": "Hello"}
644
+ ],
645
+ "stream": true
646
+ }'
647
+ ```
648
+
649
+ ##### Antigravity Gemini 格式端点
650
+
651
+ **非流式端点:** `/antigravity/v1/models/{model}:generateContent`
652
+ **流式端点:** `/antigravity/v1/models/{model}:streamGenerateContent`
653
+
654
+ **认证方式(任选一种):**
655
+ - `Authorization: Bearer your_api_password`
656
+ - `x-goog-api-key: your_api_password`
657
+ - URL 参数:`?key=your_api_password`
658
+
659
+ **请求示例:**
660
+ ```bash
661
+ # Gemini 格式非流式请求
662
+ curl -X POST "http://127.0.0.1:7861/antigravity/v1/models/claude-sonnet-4-5:generateContent" \
663
+ -H "x-goog-api-key: your_api_password" \
664
+ -H "Content-Type: application/json" \
665
+ -d '{
666
+ "contents": [
667
+ {"role": "user", "parts": [{"text": "Hello"}]}
668
+ ],
669
+ "generationConfig": {
670
+ "temperature": 0.7
671
+ }
672
+ }'
673
+
674
+ # Gemini 格式流式请求
675
+ curl -X POST "http://127.0.0.1:7861/antigravity/v1/models/gemini-2.5-flash:streamGenerateContent?key=your_api_password" \
676
+ -H "Content-Type: application/json" \
677
+ -d '{
678
+ "contents": [
679
+ {"role": "user", "parts": [{"text": "Hello"}]}
680
+ ]
681
+ }'
682
+ ```
683
+
684
+ ##### Antigravity Claude 格式端点
685
+
686
+ **端点:** `/antigravity/v1/messages`
687
+ **认证:** `x-api-key: your_api_password`
688
+
689
+ **请求示例:**
690
+ ```bash
691
+ curl -X POST "http://127.0.0.1:7861/antigravity/v1/messages" \
692
+ -H "x-api-key: your_api_password" \
693
+ -H "anthropic-version: 2023-06-01" \
694
+ -H "Content-Type: application/json" \
695
+ -d '{
696
+ "model": "claude-sonnet-4-5",
697
+ "max_tokens": 1024,
698
+ "messages": [
699
+ {"role": "user", "content": "Hello"}
700
+ ]
701
+ }'
702
+ ```
703
+
704
+ **支持的 Antigravity 模型:**
705
+ - Claude 系列:`claude-sonnet-4-5`、`claude-opus-4-5` 等
706
+ - Gemini 系列:`gemini-2.5-flash`、`gemini-2.5-pro` 等
707
+ - 自动支持思维模型(thinking models)
708
+
709
+ **Gemini 原生示例:**
710
+ ```python
711
+ from io import BytesIO
712
+ from PIL import Image
713
+ from google.genai import Client
714
+ from google.genai.types import HttpOptions
715
+ from google.genai import types
716
+ # The client gets the API key from the environment variable `GEMINI_API_KEY`.
717
+
718
+ client = Client(
719
+ api_key="pwd",
720
+ http_options=HttpOptions(base_url="http://127.0.0.1:7861"),
721
+ )
722
+
723
+ prompt = (
724
+ """
725
+ 画一只猫
726
+ """
727
+ )
728
+
729
+ response = client.models.generate_content(
730
+ model="gemini-3.1-flash-image",
731
+ contents=[prompt],
732
+ config=types.GenerateContentConfig(
733
+ image_config=types.ImageConfig(
734
+ aspect_ratio="16:9",
735
+ )
736
+ )
737
+ )
738
+ for part in response.candidates[0].content.parts:
739
+ if part.text is not None:
740
+ print(part.text)
741
+ elif part.inline_data is not None:
742
+ image = Image.open(BytesIO(part.inline_data.data))
743
+ image.save("generated_image.png")
744
+
745
+ ```
746
+
747
+ **说明:**
748
+ - OpenAI 端点返回 OpenAI 兼容格式
749
+ - Gemini 端点返回 Gemini 原生格式
750
+ - 两种端点使用相同的 API 密码
751
+
752
+ ## 📋 完整 API 参考
753
+
754
+ ### Web 控制台 API
755
+
756
+ **认证端点**
757
+ - `POST /auth/login` - 用户登录
758
+ - `POST /auth/start` - 开始 OAuth 认证(支持 GCLI 和 Antigravity 模式)
759
+ - `POST /auth/callback` - 处理 OAuth 回调
760
+ - `POST /auth/callback-url` - 从回调 URL 直接完成认证
761
+ - `GET /auth/status/{project_id}` - 检查认证状态
762
+
763
+ **凭证管理端点**(支持 `mode=geminicli` 或 `mode=antigravity` 参数)
764
+ - `POST /creds/upload` - 批量上传凭证文件(支持 JSON 和 ZIP)
765
+ - `GET /creds/status` - 获取凭证状态列表(支持分页和筛选)
766
+ - `GET /creds/detail/{filename}` - 获取单个凭证详情
767
+ - `POST /creds/action` - 单个凭证操作(启用/禁用/删除)
768
+ - `POST /creds/batch-action` - 批量凭证操作
769
+ - `GET /creds/download/{filename}` - 下载单个凭证文件
770
+ - `GET /creds/download-all` - 打包下载所有凭证
771
+ - `POST /creds/fetch-email/{filename}` - 获取用户邮箱
772
+ - `POST /creds/refresh-all-emails` - 批量刷新用户邮箱
773
+ - `POST /creds/deduplicate-by-email` - 按邮箱去重凭证
774
+ - `POST /creds/verify-project/{filename}` - 检验凭证 Project ID
775
+ - `GET /creds/quota/{filename}` - 获取凭证额度信息(仅 Antigravity)
776
+
777
+ **配置管理端点**
778
+ - `GET /config/get` - 获取当前配置
779
+ - `POST /config/save` - 保存配置
780
+
781
+ **日志管理端点**
782
+ - `POST /logs/clear` - 清空日志
783
+ - `GET /logs/download` - 下载日志文件
784
+ - `WebSocket /logs/stream` - 实时日志流
785
+
786
+ **版本信息端点**
787
+ - `GET /version/info` - 获取版本信息(可选 `check_update=true` 参数检查更新)
788
+
789
+ ### 聊天 API 功能特性
790
+
791
+ **多模态支持**
792
+ ```json
793
+ {
794
+ "model": "gemini-2.5-pro",
795
+ "messages": [
796
+ {
797
+ "role": "user",
798
+ "content": [
799
+ {"type": "text", "text": "描述这张图片"},
800
+ {
801
+ "type": "image_url",
802
+ "image_url": {
803
+ "url": "data:image/jpeg;base64,/9j/4AAQSkZJRgABA..."
804
+ }
805
+ }
806
+ ]
807
+ }
808
+ ]
809
+ }
810
+ ```
811
+
812
+ **思维模式支持**
813
+ ```json
814
+ {
815
+ "model": "gemini-2.5-pro-maxthinking",
816
+ "messages": [
817
+ {"role": "user", "content": "复杂数学问题"}
818
+ ]
819
+ }
820
+ ```
821
+
822
+ 响应将包含分离的思维内容:
823
+ ```json
824
+ {
825
+ "choices": [{
826
+ "message": {
827
+ "role": "assistant",
828
+ "content": "最终答案",
829
+ "reasoning_content": "详细的思考过程..."
830
+ }
831
+ }]
832
+ }
833
+ ```
834
+
835
+ **流式抗截断使用**
836
+ ```json
837
+ {
838
+ "model": "流式抗截断/gemini-2.5-pro",
839
+ "messages": [
840
+ {"role": "user", "content": "写一篇长文章"}
841
+ ],
842
+ "stream": true
843
+ }
844
+ ```
845
+
846
+ **兼容性模式**
847
+ ```bash
848
+ # 启用兼容性模式
849
+ export COMPATIBILITY_MODE=true
850
+ ```
851
+ 此模式下,所有 `system` 消息会转换为 `user` 消息,提高与某些客户端的兼容性。
852
+
853
+ ---
854
+
855
+ ## 💬 交流群
856
+
857
+ 欢迎加入 QQ 群交流讨论!
858
+
859
+ **QQ 群号:1083250744**
860
+
861
+ <img src="docs/qq群.jpg" width="200" alt="QQ群二维码">
862
+
863
+ ---
864
+
865
+ ## 许可证与免责声明
866
+
867
+ 本项目仅供学习和研究用途。使用本项目表示您同意:
868
+ - 不将本项目用于任何商业用途
869
+ - 承担使用本项目的所有风险和责任
870
+ - 遵守相关的服务条款和法律法规
871
+
872
+ 项目作者对因使用本项目而产生的任何直接或间接损失不承担责任。
873
+
874
+ ## ⚠️ 许可证声明
875
+
876
+ **本项目采用 Cooperative Non-Commercial License (CNC-1.0)**
877
+
878
+ 这是一个反商业化的严格开源协议,详情请查看 [LICENSE](LICENSE) 文件。
879
+
880
+ ### ✅ 允许的用途:
881
+ - 个人学习、研究、教育用途
882
+ - 非营利组织使用
883
+ - 开源项目集成(需遵循相同协议)
884
+ - 学术研究和论文发表
885
+
886
+ ### ❌ 禁止的用途:
887
+ - 任何形式的商业使用
888
+ - 年收入超过100万美元的企业使用
889
+ - 风投支持或公开交易的公司使用
890
+ - 提供付费服务或产品
891
+ - 商业竞争用途
config.py ADDED
@@ -0,0 +1,496 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ "ANTIGRAVITY_SWITCH_CREDENTIAL": "antigravity_switch_credential_enabled",
43
+ "HOST": "host",
44
+ "PORT": "port",
45
+ "API_PASSWORD": "api_password",
46
+ "PANEL_PASSWORD": "panel_password",
47
+ "PASSWORD": "password",
48
+ "KEEPALIVE_URL": "keepalive_url",
49
+ "KEEPALIVE_INTERVAL": "keepalive_interval",
50
+ }
51
+
52
+
53
+ # ====================== 配置系统 ======================
54
+
55
+ async def init_config():
56
+ """初始化配置缓存(启动时调用一次)"""
57
+ global _config_cache, _config_initialized
58
+
59
+ if _config_initialized:
60
+ return
61
+
62
+ try:
63
+ from src.storage_adapter import get_storage_adapter
64
+ storage_adapter = await get_storage_adapter()
65
+ _config_cache = await storage_adapter.get_all_config()
66
+ _config_initialized = True
67
+ except Exception:
68
+ # 初始化失败时使用空缓存
69
+ _config_cache = {}
70
+ _config_initialized = True
71
+
72
+
73
+ async def reload_config():
74
+ """重新加载配置(修改配置后调用)"""
75
+ global _config_cache, _config_initialized
76
+
77
+ try:
78
+ from src.storage_adapter import get_storage_adapter
79
+ storage_adapter = await get_storage_adapter()
80
+
81
+ # 如果后端支持 reload_config_cache,调用它
82
+ if hasattr(storage_adapter._backend, 'reload_config_cache'):
83
+ await storage_adapter._backend.reload_config_cache()
84
+
85
+ # 重新加载配置缓存
86
+ _config_cache = await storage_adapter.get_all_config()
87
+ _config_initialized = True
88
+ except Exception:
89
+ pass
90
+
91
+
92
+ def _get_cached_config(key: str, default: Any = None) -> Any:
93
+ """从内存缓存获取配置(同步)"""
94
+ return _config_cache.get(key, default)
95
+
96
+
97
+ async def get_config_value(key: str, default: Any = None, env_var: Optional[str] = None) -> Any:
98
+ """Get configuration value with priority: ENV > Storage > default."""
99
+ # 确保配置已初始化
100
+ if not _config_initialized:
101
+ await init_config()
102
+
103
+ # Priority 1: Environment variable
104
+ if env_var and os.getenv(env_var):
105
+ return os.getenv(env_var)
106
+
107
+ # Priority 2: Memory cache
108
+ value = _get_cached_config(key)
109
+ if value is not None:
110
+ return value
111
+
112
+ return default
113
+
114
+
115
+ # Configuration getters - all async
116
+ async def get_proxy_config():
117
+ """Get proxy configuration."""
118
+ proxy_url = await get_config_value("proxy", env_var="PROXY")
119
+ return proxy_url if proxy_url else None
120
+
121
+
122
+ async def get_auto_ban_enabled() -> bool:
123
+ """Get auto ban enabled setting."""
124
+ env_value = os.getenv("AUTO_BAN")
125
+ if env_value:
126
+ return env_value.lower() in ("true", "1", "yes", "on")
127
+
128
+ return bool(await get_config_value("auto_ban_enabled", False))
129
+
130
+
131
+ async def get_auto_ban_error_codes() -> list:
132
+ """
133
+ Get auto ban error codes.
134
+
135
+ Environment variable: AUTO_BAN_ERROR_CODES (comma-separated, e.g., "400,403")
136
+ Database config key: auto_ban_error_codes
137
+ Default: [400, 403]
138
+ """
139
+ env_value = os.getenv("AUTO_BAN_ERROR_CODES")
140
+ if env_value:
141
+ try:
142
+ return [int(code.strip()) for code in env_value.split(",") if code.strip()]
143
+ except ValueError:
144
+ pass
145
+
146
+ codes = await get_config_value("auto_ban_error_codes")
147
+ if codes and isinstance(codes, list):
148
+ return codes
149
+ return AUTO_BAN_ERROR_CODES
150
+
151
+
152
+ async def get_retry_429_max_retries() -> int:
153
+ """Get max retries for 429 errors."""
154
+ env_value = os.getenv("RETRY_429_MAX_RETRIES")
155
+ if env_value:
156
+ try:
157
+ return int(env_value)
158
+ except ValueError:
159
+ pass
160
+
161
+ return int(await get_config_value("retry_429_max_retries", 5))
162
+
163
+
164
+ async def get_retry_429_enabled() -> bool:
165
+ """Get 429 retry enabled setting."""
166
+ env_value = os.getenv("RETRY_429_ENABLED")
167
+ if env_value:
168
+ return env_value.lower() in ("true", "1", "yes", "on")
169
+
170
+ return bool(await get_config_value("retry_429_enabled", True))
171
+
172
+
173
+ async def get_retry_429_interval() -> float:
174
+ """Get 429 retry interval in seconds."""
175
+ env_value = os.getenv("RETRY_429_INTERVAL")
176
+ if env_value:
177
+ try:
178
+ return float(env_value)
179
+ except ValueError:
180
+ pass
181
+
182
+ return float(await get_config_value("retry_429_interval", 1))
183
+
184
+
185
+ async def get_anti_truncation_max_attempts() -> int:
186
+ """
187
+ Get maximum attempts for anti-truncation continuation.
188
+
189
+ Environment variable: ANTI_TRUNCATION_MAX_ATTEMPTS
190
+ Database config key: anti_truncation_max_attempts
191
+ Default: 3
192
+ """
193
+ env_value = os.getenv("ANTI_TRUNCATION_MAX_ATTEMPTS")
194
+ if env_value:
195
+ try:
196
+ return int(env_value)
197
+ except ValueError:
198
+ pass
199
+
200
+ return int(await get_config_value("anti_truncation_max_attempts", 3))
201
+
202
+
203
+ # Server Configuration
204
+ async def get_server_host() -> str:
205
+ """
206
+ Get server host setting.
207
+
208
+ Environment variable: HOST
209
+ Database config key: host
210
+ Default: 0.0.0.0
211
+ """
212
+ return str(await get_config_value("host", "0.0.0.0", "HOST"))
213
+
214
+
215
+ async def get_server_port() -> int:
216
+ """
217
+ Get server port setting.
218
+
219
+ Environment variable: PORT
220
+ Database config key: port
221
+ Default: 7861
222
+ """
223
+ env_value = os.getenv("PORT")
224
+ if env_value:
225
+ try:
226
+ return int(env_value)
227
+ except ValueError:
228
+ pass
229
+
230
+ return int(await get_config_value("port", 7861))
231
+
232
+
233
+ async def get_api_password() -> str:
234
+ """
235
+ Get API password setting for chat endpoints.
236
+
237
+ Environment variable: API_PASSWORD
238
+ Database config key: api_password
239
+ Default: Uses PASSWORD env var for compatibility, otherwise 'pwd'
240
+ """
241
+ # 优先使用 API_PASSWORD,如果没有则使用通用 PASSWORD 保证兼容性
242
+ api_password = await get_config_value("api_password", None, "API_PASSWORD")
243
+ if api_password is not None:
244
+ return str(api_password)
245
+
246
+ # 兼容性:使用通用密码
247
+ return str(await get_config_value("password", "pwd", "PASSWORD"))
248
+
249
+
250
+ async def get_panel_password() -> str:
251
+ """
252
+ Get panel password setting for web interface.
253
+
254
+ Environment variable: PANEL_PASSWORD
255
+ Database config key: panel_password
256
+ Default: Uses PASSWORD env var for compatibility, otherwise 'pwd'
257
+ """
258
+ # 优先使用 PANEL_PASSWORD,如果没有则使用通用 PASSWORD 保证兼容性
259
+ panel_password = await get_config_value("panel_password", None, "PANEL_PASSWORD")
260
+ if panel_password is not None:
261
+ return str(panel_password)
262
+
263
+ # 兼容性:使用通用密码
264
+ return str(await get_config_value("password", "pwd", "PASSWORD"))
265
+
266
+
267
+ async def get_server_password() -> str:
268
+ """
269
+ Get server password setting (deprecated, use get_api_password or get_panel_password).
270
+
271
+ Environment variable: PASSWORD
272
+ Database config key: password
273
+ Default: pwd
274
+ """
275
+ return str(await get_config_value("password", "pwd", "PASSWORD"))
276
+
277
+
278
+ async def get_credentials_dir() -> str:
279
+ """
280
+ Get credentials directory setting.
281
+
282
+ Environment variable: CREDENTIALS_DIR
283
+ Database config key: credentials_dir
284
+ Default: ./creds
285
+ """
286
+ return str(await get_config_value("credentials_dir", "./creds", "CREDENTIALS_DIR"))
287
+
288
+
289
+ async def get_code_assist_endpoint() -> str:
290
+ """
291
+ Get Code Assist endpoint setting.
292
+
293
+ Environment variable: CODE_ASSIST_ENDPOINT
294
+ Database config key: code_assist_endpoint
295
+ Default: https://cloudcode-pa.googleapis.com
296
+ """
297
+ return str(
298
+ await get_config_value(
299
+ "code_assist_endpoint", "https://cloudcode-pa.googleapis.com", "CODE_ASSIST_ENDPOINT"
300
+ )
301
+ )
302
+
303
+
304
+ async def get_compatibility_mode_enabled() -> bool:
305
+ """
306
+ Get compatibility mode setting.
307
+
308
+ 兼容性模式:启用后所有system消息全部转换成user,停用system_instructions。
309
+ 该选项可能会降低模型理解能力,但是能避免流式空回的情况。
310
+
311
+ Environment variable: COMPATIBILITY_MODE
312
+ Database config key: compatibility_mode_enabled
313
+ Default: False
314
+ """
315
+ env_value = os.getenv("COMPATIBILITY_MODE")
316
+ if env_value:
317
+ return env_value.lower() in ("true", "1", "yes", "on")
318
+
319
+ return bool(await get_config_value("compatibility_mode_enabled", False))
320
+
321
+
322
+ async def get_return_thoughts_to_frontend() -> bool:
323
+ """
324
+ Get return thoughts to frontend setting.
325
+
326
+ 控制是否将思维链返回到前端。
327
+ 启用后,思维链会在��应中返回;禁用后,思维链会在响应中被过滤掉。
328
+
329
+ Environment variable: RETURN_THOUGHTS_TO_FRONTEND
330
+ Database config key: return_thoughts_to_frontend
331
+ Default: True
332
+ """
333
+ env_value = os.getenv("RETURN_THOUGHTS_TO_FRONTEND")
334
+ if env_value:
335
+ return env_value.lower() in ("true", "1", "yes", "on")
336
+
337
+ return bool(await get_config_value("return_thoughts_to_frontend", True))
338
+
339
+
340
+ async def get_antigravity_stream2nostream() -> bool:
341
+ """
342
+ Get use stream for non-stream setting.
343
+
344
+ 控制antigravity非流式请求是否使用流式API并收集为完整响应。
345
+ 启用后,非流式请求将在后端使用流式API,然后收集所有块后再返回完整响应。
346
+
347
+ Environment variable: ANTIGRAVITY_STREAM2NOSTREAM
348
+ Database config key: antigravity_stream2nostream
349
+ Default: True
350
+ """
351
+ env_value = os.getenv("ANTIGRAVITY_STREAM2NOSTREAM")
352
+ if env_value:
353
+ return env_value.lower() in ("true", "1", "yes", "on")
354
+
355
+ return bool(await get_config_value("antigravity_stream2nostream", True))
356
+
357
+
358
+ async def get_antigravity_switch_credential_enabled() -> bool:
359
+ """
360
+ Get antigravity switch credential setting.
361
+
362
+ 控制antigravity在重试时是否切换凭证。
363
+ 禁用时会持续使用当前凭证,直到该凭证对当前模型进入CD或被禁用。
364
+
365
+ Environment variable: ANTIGRAVITY_SWITCH_CREDENTIAL
366
+ Database config key: antigravity_switch_credential_enabled
367
+ Default: False
368
+ """
369
+ env_value = os.getenv("ANTIGRAVITY_SWITCH_CREDENTIAL")
370
+ if env_value:
371
+ return env_value.lower() in ("true", "1", "yes", "on")
372
+
373
+ return bool(await get_config_value("antigravity_switch_credential_enabled", False))
374
+
375
+
376
+ async def get_oauth_proxy_url() -> str:
377
+ """
378
+ Get OAuth proxy URL setting.
379
+
380
+ 用于Google OAuth2认证的代理URL。
381
+
382
+ Environment variable: OAUTH_PROXY_URL
383
+ Database config key: oauth_proxy_url
384
+ Default: https://oauth2.googleapis.com
385
+ """
386
+ return str(
387
+ await get_config_value(
388
+ "oauth_proxy_url", "https://oauth2.googleapis.com", "OAUTH_PROXY_URL"
389
+ )
390
+ )
391
+
392
+
393
+ async def get_googleapis_proxy_url() -> str:
394
+ """
395
+ Get Google APIs proxy URL setting.
396
+
397
+ 用于Google APIs调用的代理URL。
398
+
399
+ Environment variable: GOOGLEAPIS_PROXY_URL
400
+ Database config key: googleapis_proxy_url
401
+ Default: https://www.googleapis.com
402
+ """
403
+ return str(
404
+ await get_config_value(
405
+ "googleapis_proxy_url", "https://www.googleapis.com", "GOOGLEAPIS_PROXY_URL"
406
+ )
407
+ )
408
+
409
+
410
+ async def get_resource_manager_api_url() -> str:
411
+ """
412
+ Get Google Cloud Resource Manager API URL setting.
413
+
414
+ 用于Google Cloud Resource Manager API的URL。
415
+
416
+ Environment variable: RESOURCE_MANAGER_API_URL
417
+ Database config key: resource_manager_api_url
418
+ Default: https://cloudresourcemanager.googleapis.com
419
+ """
420
+ return str(
421
+ await get_config_value(
422
+ "resource_manager_api_url",
423
+ "https://cloudresourcemanager.googleapis.com",
424
+ "RESOURCE_MANAGER_API_URL",
425
+ )
426
+ )
427
+
428
+
429
+ async def get_service_usage_api_url() -> str:
430
+ """
431
+ Get Google Cloud Service Usage API URL setting.
432
+
433
+ 用于Google Cloud Service Usage API的URL。
434
+
435
+ Environment variable: SERVICE_USAGE_API_URL
436
+ Database config key: service_usage_api_url
437
+ Default: https://serviceusage.googleapis.com
438
+ """
439
+ return str(
440
+ await get_config_value(
441
+ "service_usage_api_url", "https://serviceusage.googleapis.com", "SERVICE_USAGE_API_URL"
442
+ )
443
+ )
444
+
445
+
446
+ async def get_antigravity_api_url() -> str:
447
+ """
448
+ Get Antigravity API URL setting.
449
+
450
+ 用于Google Antigravity API的URL。
451
+
452
+ Environment variable: ANTIGRAVITY_API_URL
453
+ Database config key: antigravity_api_url
454
+ Default: https://daily-cloudcode-pa.googleapis.com
455
+ """
456
+ return str(
457
+ await get_config_value(
458
+ "antigravity_api_url",
459
+ "https://daily-cloudcode-pa.googleapis.com",
460
+ "ANTIGRAVITY_API_URL",
461
+ )
462
+ )
463
+
464
+
465
+ async def get_keepalive_url() -> str:
466
+ """
467
+ Get keep-alive URL setting.
468
+
469
+ 配置后保活服务会定期向该URL发送GET请求。
470
+ 留空表示禁用保活服务。
471
+
472
+ Environment variable: KEEPALIVE_URL
473
+ Database config key: keepalive_url
474
+ Default: "" (disabled)
475
+ """
476
+ return str(await get_config_value("keepalive_url", "", "KEEPALIVE_URL"))
477
+
478
+
479
+ async def get_keepalive_interval() -> int:
480
+ """
481
+ Get keep-alive interval in seconds.
482
+
483
+ 保活请求发送间隔(秒)。
484
+
485
+ Environment variable: KEEPALIVE_INTERVAL
486
+ Database config key: keepalive_interval
487
+ Default: 60
488
+ """
489
+ env_value = os.getenv("KEEPALIVE_INTERVAL")
490
+ if env_value:
491
+ try:
492
+ return int(env_value)
493
+ except ValueError:
494
+ pass
495
+
496
+ return int(await get_config_value("keepalive_interval", 60))
darwin-install.sh ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+ # macOS 安装脚本 (支持 Intel 和 Apple Silicon)
3
+
4
+ # 确保 Homebrew 已安装
5
+ if ! command -v brew &> /dev/null; then
6
+ echo "未检测到 Homebrew,开始安装..."
7
+ /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
8
+
9
+ # 检测 Homebrew 安装路径并设置环境变量
10
+ if [[ -f "/opt/homebrew/bin/brew" ]]; then
11
+ # Apple Silicon Mac
12
+ eval "$(/opt/homebrew/bin/brew shellenv)"
13
+ elif [[ -f "/usr/local/bin/brew" ]]; then
14
+ # Intel Mac
15
+ eval "$(/usr/local/bin/brew shellenv)"
16
+ fi
17
+ fi
18
+
19
+ # 更新 brew 并安装 git
20
+ brew update
21
+ brew install git
22
+
23
+ # 安装 uv (Python 环境管理工具)
24
+ curl -Ls https://astral.sh/uv/install.sh | sh
25
+
26
+ # 确保 uv 在 PATH 中
27
+ export PATH="$HOME/.local/bin:$PATH"
28
+
29
+ # 克隆或进入项目目录
30
+ if [ -f "./web.py" ]; then
31
+ # 已经在目标目录
32
+ :
33
+ elif [ -f "./gcli2api/web.py" ]; then
34
+ cd ./gcli2api
35
+ else
36
+ git clone https://github.com/su-kaka/gcli2api.git
37
+ cd ./gcli2api
38
+ fi
39
+
40
+ # 拉取最新代码
41
+ git pull
42
+
43
+ # 创建并同步虚拟环境
44
+ uv sync
45
+
46
+ # 激活虚拟环境
47
+ if [ -f ".venv/bin/activate" ]; then
48
+ source .venv/bin/activate
49
+ else
50
+ echo "❌ 未找到虚拟环境,请检查 uv 是否安装成功"
51
+ exit 1
52
+ fi
53
+
54
+ # 启动项目
55
+ python3 web.py
docker-compose.yml ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ version: '3.8'
2
+
3
+ services:
4
+ gcli2api:
5
+ image: ghcr.io/su-kaka/gcli2api:latest
6
+ container_name: gcli2api
7
+ restart: unless-stopped
8
+ network_mode: host
9
+ environment:
10
+ # Password configuration (choose one)
11
+ # Option 1: Use common password
12
+ - PASSWORD=${PASSWORD:-pwd}
13
+ # Option 2: Use separate passwords (uncomment if needed)
14
+ # - API_PASSWORD=${API_PASSWORD:-your_api_password}
15
+ # - PANEL_PASSWORD=${PANEL_PASSWORD:-your_panel_password}
16
+
17
+ # Server configuration
18
+ - PORT=${PORT:-7861}
19
+ - HOST=${HOST:-0.0.0.0}
20
+
21
+ # Optional: Google credentials from environment
22
+ # - GOOGLE_CREDENTIALS=${GOOGLE_CREDENTIALS}
23
+
24
+ # Optional: Logging configuration
25
+ # - LOG_LEVEL=${LOG_LEVEL:-info}
26
+
27
+ # Optional: Redis configuration (for distributed storage)
28
+ # - REDIS_URI=${REDIS_URI}
29
+ # - REDIS_DATABASE=${REDIS_DATABASE:-0}
30
+
31
+ # Optional: MongoDB configuration (for distributed storage)
32
+ # - MONGODB_URI=${MONGODB_URI}
33
+ # - MONGODB_DATABASE=${MONGODB_DATABASE:-gcli2api}
34
+
35
+ # Optional: PostgreSQL configuration (for distributed storage)
36
+ # - POSTGRES_DSN=${POSTGRES_DSN}
37
+
38
+ # Optional: Proxy configuration
39
+ # - PROXY=${PROXY}
40
+ volumes:
41
+ - ./data/creds:/app/creds
42
+
43
+ # Example with Redis for distributed storage
44
+ # redis:
45
+ # image: redis:7-alpine
46
+ # container_name: gcli2api-redis
47
+ # restart: unless-stopped
48
+ # ports:
49
+ # - "6379:6379"
50
+ # volumes:
51
+ # - redis_data:/data
52
+ # command: redis-server --appendonly yes
53
+ # healthcheck:
54
+ # test: ["CMD", "redis-cli", "ping"]
55
+ # interval: 10s
56
+ # timeout: 3s
57
+ # retries: 3
58
+
59
+ # Example with MongoDB for distributed storage
60
+ # mongodb:
61
+ # image: mongo:7
62
+ # container_name: gcli2api-mongodb
63
+ # restart: unless-stopped
64
+ # environment:
65
+ # MONGO_INITDB_ROOT_USERNAME: ${MONGO_USER:-admin}
66
+ # MONGO_INITDB_ROOT_PASSWORD: ${MONGO_PASSWORD:-password}
67
+ # ports:
68
+ # - "27017:27017"
69
+ # volumes:
70
+ # - mongodb_data:/data/db
71
+ # healthcheck:
72
+ # test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"]
73
+ # interval: 10s
74
+ # timeout: 5s
75
+ # retries: 3
76
+
77
+ #volumes:
78
+ # redis_data:
79
+ # mongodb_data:
docs/README_EN.md ADDED
@@ -0,0 +1,883 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 | [日本語](./README_JA.md)
10
+
11
+ ## 🚀 Quick Deploy
12
+
13
+ [![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/templates/97VMEF?referralCode=sukaka)
14
+ [![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo=https://github.com/su-kaka/gcli2api)
15
+ ---
16
+
17
+ ## Installation Guide
18
+
19
+ ### Termux Environment
20
+
21
+ **Initial Installation**
22
+ ```bash
23
+ 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
24
+ ```
25
+
26
+ **Restart Service**
27
+ ```bash
28
+ cd gcli2api
29
+ bash termux-start.sh
30
+ ```
31
+
32
+ ### Windows Environment
33
+
34
+ **Initial Installation**
35
+ ```powershell
36
+ iex (iwr "https://raw.githubusercontent.com/su-kaka/gcli2api/refs/heads/master/install.ps1" -UseBasicParsing).Content
37
+ ```
38
+
39
+ **Restart Service**
40
+ Double-click to execute `start.bat`
41
+
42
+ ### Linux Environment
43
+
44
+ **Initial Installation**
45
+ ```bash
46
+ curl -o install.sh "https://raw.githubusercontent.com/su-kaka/gcli2api/refs/heads/master/install.sh" && chmod +x install.sh && ./install.sh
47
+ ```
48
+
49
+ **Restart Service**
50
+ ```bash
51
+ cd gcli2api
52
+ bash start.sh
53
+ ```
54
+
55
+ ### macOS Environment
56
+
57
+ **Initial Installation**
58
+ ```bash
59
+ curl -o darwin-install.sh "https://raw.githubusercontent.com/su-kaka/gcli2api/refs/heads/master/darwin-install.sh" && chmod +x darwin-install.sh && ./darwin-install.sh
60
+ ```
61
+
62
+ **Restart Service**
63
+ ```bash
64
+ cd gcli2api
65
+ bash start.sh
66
+ ```
67
+
68
+ ### Docker Environment
69
+
70
+ **Docker Run Command**
71
+ ```bash
72
+ # Using universal password
73
+ 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
74
+
75
+ # Using separate passwords
76
+ 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
77
+ ```
78
+
79
+ **Docker Mac**
80
+ ```bash
81
+ # Using universal password
82
+ docker run -d \
83
+ --name gcli2api \
84
+ -p 7861:7861 \
85
+ -p 8080:8080 \
86
+ -e PASSWORD=pwd \
87
+ -e PORT=7861 \
88
+ -v "$(pwd)/data/creds":/app/creds \
89
+ ghcr.io/su-kaka/gcli2api:latest
90
+ ```
91
+
92
+ ```bash
93
+ # Using separate passwords
94
+ docker run -d \
95
+ --name gcli2api \
96
+ -p 7861:7861 \
97
+ -p 8080:8080 \
98
+ -e API_PASSWORD=api_pwd \
99
+ -e PANEL_PASSWORD=panel_pwd \
100
+ -e PORT=7861 \
101
+ -v $(pwd)/data/creds:/app/creds \
102
+ ghcr.io/su-kaka/gcli2api:latest
103
+ ```
104
+
105
+ **Docker Compose Run Command**
106
+ 1. Save the following content as `docker-compose.yml` file:
107
+ ```yaml
108
+ version: '3.8'
109
+
110
+ services:
111
+ gcli2api:
112
+ image: ghcr.io/su-kaka/gcli2api:latest
113
+ container_name: gcli2api
114
+ restart: unless-stopped
115
+ network_mode: host
116
+ environment:
117
+ # Using universal password (recommended for simple deployment)
118
+ - PASSWORD=pwd
119
+ - PORT=7861
120
+ # Or use separate passwords (recommended for production)
121
+ # - API_PASSWORD=your_api_password
122
+ # - PANEL_PASSWORD=your_panel_password
123
+ volumes:
124
+ - ./data/creds:/app/creds
125
+ healthcheck:
126
+ 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)\""]
127
+ interval: 30s
128
+ timeout: 10s
129
+ retries: 3
130
+ start_period: 40s
131
+ ```
132
+ 2. Start the service:
133
+ ```bash
134
+ docker-compose up -d
135
+ ```
136
+
137
+ ## Core Features
138
+
139
+ ### 🔄 API Endpoints and Format Support
140
+
141
+ **Multi-endpoint Multi-format Support**
142
+ - **OpenAI Compatible Endpoints**: `/v1/chat/completions` and `/v1/models`
143
+ - Supports standard OpenAI format (messages structure)
144
+ - Supports Gemini native format (contents structure)
145
+ - Automatic format detection and conversion, no manual switching required
146
+ - Supports multimodal input (text + images)
147
+ - **Gemini Native Endpoints**: `/v1/models/{model}:generateContent` and `streamGenerateContent`
148
+ - Supports complete Gemini native API specifications
149
+ - Multiple authentication methods: Bearer Token, x-goog-api-key header, URL parameter key
150
+ - **Claude Format Compatibility**: Full support for Claude API format
151
+ - Endpoint: `/v1/messages` (follows Claude API specification)
152
+ - Supports Claude standard messages format
153
+ - Supports system parameter and Claude-specific features
154
+ - Automatically converts to backend-supported format
155
+ - **Antigravity API Support**: Supports OpenAI, Gemini, and Claude formats
156
+ - OpenAI format endpoint: `/antigravity/v1/chat/completions`
157
+ - Gemini format endpoint: `/antigravity/v1/models/{model}:generateContent` and `streamGenerateContent`
158
+ - Claude format endpoint: `/antigravity/v1/messages`
159
+ - Supports all Antigravity models (Claude, Gemini, etc.)
160
+ - Automatic model name mapping and thinking mode detection
161
+
162
+ ### 🔐 Authentication and Security Management
163
+
164
+ **Flexible Password Management**
165
+ - **Separate Password Support**: API password (chat endpoints) and control panel password can be set independently
166
+ - **Multiple Authentication Methods**: Supports Authorization Bearer, x-goog-api-key header, URL parameters, etc.
167
+ - **JWT Token Authentication**: Control panel supports JWT token authentication
168
+ - **User Email Retrieval**: Automatically retrieves and displays Google account email addresses
169
+
170
+ ### 📊 Intelligent Credential Management System
171
+
172
+ **Advanced Credential Management**
173
+ - Multiple Google OAuth credential automatic rotation
174
+ - Enhanced stability through redundant authentication
175
+ - Load balancing and concurrent request support
176
+ - Automatic failure detection and credential disabling
177
+ - Credential usage statistics and quota management
178
+ - Support for manual enable/disable credential files
179
+ - Batch credential file operations (enable, disable, delete)
180
+
181
+ **Credential Status Monitoring**
182
+ - Real-time credential health checks
183
+ - Error code tracking (429, 403, 500, etc.)
184
+ - Automatic banning mechanism (configurable)
185
+
186
+ ### 🌊 Streaming and Response Processing
187
+
188
+ **Multiple Streaming Support**
189
+ - True real-time streaming responses
190
+ - Fake streaming mode (for compatibility)
191
+ - Streaming anti-truncation feature (prevents answer truncation)
192
+ - Asynchronous task management and timeout handling
193
+
194
+ **Response Optimization**
195
+ - Thinking chain content separation
196
+ - Reasoning process (reasoning_content) handling
197
+ - Multi-turn conversation context management
198
+ - Compatibility mode (converts system messages to user messages)
199
+
200
+ ### 🎛️ Web Management Console
201
+
202
+ **Full-featured Web Interface**
203
+ - OAuth authentication flow management (supports GCLI and Antigravity dual modes)
204
+ - Credential file upload, download, and management
205
+ - Real-time log viewing (WebSocket)
206
+ - System configuration management
207
+ - Usage statistics and monitoring dashboard
208
+ - Mobile-friendly interface
209
+
210
+ **Batch Operation Support**
211
+ - ZIP file batch credential upload (GCLI and Antigravity)
212
+ - Batch enable/disable/delete credentials
213
+ - Batch user email retrieval
214
+ - Batch configuration management
215
+ - Unified batch upload interface for all credential types
216
+
217
+ ### 📈 Usage Monitoring
218
+
219
+ **Real-time Monitoring**
220
+ - WebSocket real-time log streams
221
+ - System status monitoring
222
+ - Credential health status
223
+
224
+ ### 🔧 Advanced Configuration and Customization
225
+
226
+ **Network and Proxy Configuration**
227
+ - HTTP/HTTPS proxy support
228
+ - Proxy endpoint configuration (OAuth, Google APIs, metadata service)
229
+ - Timeout and retry configuration
230
+ - Network error handling and recovery
231
+
232
+ **Performance and Stability Configuration**
233
+ - 429 error automatic retry (configurable interval and attempts)
234
+ - Anti-truncation maximum retry attempts
235
+
236
+ **Logging and Debugging**
237
+ - Multi-level logging system (DEBUG, INFO, WARNING, ERROR)
238
+ - Log file management
239
+ - Real-time log streams
240
+ - Log download and clearing
241
+
242
+ ### 🔄 Environment Variables and Configuration Management
243
+
244
+ **Flexible Configuration Methods**
245
+ - Environment variable configuration
246
+ - Hot configuration updates (partial configuration items)
247
+ - Configuration locking (environment variable priority)
248
+
249
+ ## Supported Models
250
+
251
+ All models have 1M context window capacity. Each credential file provides 1000 request quota.
252
+
253
+ ### 🤖 Base Models
254
+ - `gemini-2.5-pro`
255
+ - `gemini-3-pro-preview`
256
+ - `gemini-3.1-pro-preview`
257
+
258
+ ### 🧠 Thinking Models
259
+ - `gemini-2.5-pro-high`: Thinking mode
260
+ - `gemini-2.5-pro-low`: Low thinking mode
261
+ - Supports custom thinking budget configuration
262
+ - Automatic separation of thinking content and final answers
263
+
264
+ ### 🔍 Search-Enhanced Models
265
+ - `gemini-2.5-pro-search`: Model with integrated search functionality
266
+
267
+ ### 🖼️ Image Generation Models (Antigravity)
268
+ - `gemini-3.1-flash-image`: Base image generation model
269
+ - **Resolution Suffixes**:
270
+ - `-2k`: 2K resolution
271
+ - `-4k`: 4K HD resolution
272
+ - **Aspect Ratio Suffixes**:
273
+ - `-1x1`: Square (avatar)
274
+ - `-16x9`: Landscape (desktop wallpaper)
275
+ - `-9x16`: Portrait (mobile wallpaper)
276
+ - `-21x9`: Ultra-wide (ultrawide monitor)
277
+ - `-4x3`: Traditional display
278
+ - `-3x4`: Portrait poster
279
+ - **Combination Examples**:
280
+ - `gemini-3.1-flash-image-4k-16x9`: 4K landscape
281
+ - `gemini-3.1-flash-image-2k-9x16`: 2K portrait
282
+ - When no ratio is specified, the API automatically decides the aspect ratio
283
+
284
+ ### 🌊 Special Feature Variants
285
+ - **Fake Streaming Mode**: Add `-假流式` suffix to any model name
286
+ - Example: `gemini-2.5-pro-假流式`
287
+ - For scenarios requiring streaming responses but server doesn't support true streaming
288
+ - **Streaming Anti-truncation Mode**: Add `流式抗截断/` prefix to model name
289
+ - Example: `流式抗截断/gemini-2.5-pro`
290
+ - Automatically detects response truncation and retries to ensure complete answers
291
+
292
+ ### 🔧 Automatic Model Feature Detection
293
+ - System automatically recognizes feature identifiers in model names
294
+ - Transparently handles feature mode transitions
295
+ - Supports feature combination usage
296
+
297
+ ---
298
+
299
+ ## Configuration Instructions
300
+
301
+ 1. Visit `http://127.0.0.1:7861` (default port, modifiable via PORT environment variable)
302
+ 2. Complete OAuth authentication flow (default password: `pwd`, modifiable via environment variables)
303
+ - **GCLI Mode**: For obtaining Google Cloud Gemini API credentials
304
+ - **Antigravity Mode**: For obtaining Google Antigravity API credentials
305
+ 3. Configure client:
306
+
307
+ **OpenAI Compatible Client:**
308
+ - **Endpoint Address**: `http://127.0.0.1:7861/v1`
309
+ - **API Key**: `pwd` (default value, modifiable via API_PASSWORD or PASSWORD environment variables)
310
+
311
+ **Gemini Native Client:**
312
+ - **Endpoint Address**: `http://127.0.0.1:7861`
313
+ - **Authentication Methods**:
314
+ - `Authorization: Bearer your_api_password`
315
+ - `x-goog-api-key: your_api_password`
316
+ - URL parameter: `?key=your_api_password`
317
+
318
+ ### 🌟 Dual Authentication Mode Support
319
+
320
+ **GCLI Authentication Mode**
321
+ - Standard Google Cloud Gemini API authentication
322
+ - Supports OAuth2.0 authentication flow
323
+ - Automatically enables required Google Cloud APIs
324
+
325
+ **Antigravity Authentication Mode**
326
+ - Dedicated authentication for Google Antigravity API
327
+ - Independent credential management system
328
+ - Supports batch upload and management
329
+ - Completely isolated from GCLI credentials
330
+
331
+ **Unified Management Interface**
332
+ - Manage both credential types in the "Batch Upload" tab
333
+ - Upper section: GCLI credential batch upload (blue theme)
334
+ - Lower section: Antigravity credential batch upload (green theme)
335
+ - Separate credential management tabs for each type
336
+
337
+ ## 💾 Data Storage Mode
338
+
339
+ ### 🌟 Storage Backend Support
340
+
341
+ gcli2api supports two storage backends: **Local SQLite (Default)** and **MongoDB (Cloud Distributed Storage)**
342
+
343
+ ### 📁 Local SQLite Storage (Default)
344
+
345
+ **Default Storage Method**
346
+ - No configuration required, works out of the box
347
+ - Data is stored in a local SQLite database
348
+ - Suitable for single-machine deployment and personal use
349
+ - Automatically creates and manages database files
350
+
351
+ ### 🍃 MongoDB Cloud Storage Mode
352
+
353
+ **Cloud Distributed Storage Solution**
354
+
355
+ When multi-instance deployment or cloud storage is needed, MongoDB storage mode can be enabled.
356
+
357
+ ### ⚙️ Enable MongoDB Mode
358
+
359
+ **Step 1: Configure MongoDB Connection**
360
+ ```bash
361
+ # Local MongoDB
362
+ export MONGODB_URI="mongodb://localhost:27017"
363
+
364
+ # MongoDB Atlas cloud service
365
+ export MONGODB_URI="mongodb+srv://username:password@cluster.mongodb.net"
366
+
367
+ # MongoDB with authentication
368
+ export MONGODB_URI="mongodb://admin:password@localhost:27017/admin"
369
+
370
+ # Optional: Custom database name (default: gcli2api)
371
+ export MONGODB_DATABASE="my_gcli_db"
372
+ ```
373
+
374
+ **Step 2: Start Application**
375
+ ```bash
376
+ # Application will automatically detect MongoDB configuration and use MongoDB storage
377
+ python web.py
378
+ ```
379
+
380
+ **Docker Environment using MongoDB**
381
+ ```bash
382
+ # Single MongoDB deployment
383
+ docker run -d --name gcli2api \
384
+ -e MONGODB_URI="mongodb://mongodb:27017" \
385
+ -e API_PASSWORD=your_password \
386
+ --network your_network \
387
+ ghcr.io/su-kaka/gcli2api:latest
388
+
389
+ # Using MongoDB Atlas
390
+ docker run -d --name gcli2api \
391
+ -e MONGODB_URI="mongodb+srv://user:pass@cluster.mongodb.net/gcli2api" \
392
+ -e API_PASSWORD=your_password \
393
+ -p 7861:7861 \
394
+ ghcr.io/su-kaka/gcli2api:latest
395
+ ```
396
+
397
+ **Docker Compose Example**
398
+ ```yaml
399
+ version: '3.8'
400
+
401
+ services:
402
+ mongodb:
403
+ image: mongo:7
404
+ container_name: gcli2api-mongodb
405
+ restart: unless-stopped
406
+ environment:
407
+ MONGO_INITDB_ROOT_USERNAME: admin
408
+ MONGO_INITDB_ROOT_PASSWORD: password123
409
+ volumes:
410
+ - mongodb_data:/data/db
411
+ ports:
412
+ - "27017:27017"
413
+
414
+ gcli2api:
415
+ image: ghcr.io/su-kaka/gcli2api:latest
416
+ container_name: gcli2api
417
+ restart: unless-stopped
418
+ depends_on:
419
+ - mongodb
420
+ environment:
421
+ - MONGODB_URI=mongodb://admin:password123@mongodb:27017/admin
422
+ - MONGODB_DATABASE=gcli2api
423
+ - API_PASSWORD=your_api_password
424
+ - PORT=7861
425
+ ports:
426
+ - "7861:7861"
427
+
428
+ volumes:
429
+ mongodb_data:
430
+ ```
431
+
432
+
433
+ ### 🔧 Advanced Configuration
434
+
435
+ **MongoDB Connection Optimization**
436
+ ```bash
437
+ # Connection pool and timeout configuration
438
+ export MONGODB_URI="mongodb://localhost:27017?maxPoolSize=10&serverSelectionTimeoutMS=5000"
439
+
440
+ # Replica set configuration
441
+ export MONGODB_URI="mongodb://host1:27017,host2:27017,host3:27017/gcli2api?replicaSet=myReplicaSet"
442
+
443
+ # Read-write separation configuration
444
+ export MONGODB_URI="mongodb://localhost:27017/gcli2api?readPreference=secondaryPreferred"
445
+ ```
446
+
447
+ ### Environment Variable Configuration
448
+
449
+ **Basic Configuration**
450
+ - `PORT`: Service port (default: 7861)
451
+ - `HOST`: Server listen address (default: 0.0.0.0)
452
+
453
+ **Password Configuration**
454
+ - `API_PASSWORD`: Chat API access password (default: inherits PASSWORD or pwd)
455
+ - `PANEL_PASSWORD`: Control panel access password (default: inherits PASSWORD or pwd)
456
+ - `PASSWORD`: Universal password, overrides the above two when set (default: pwd)
457
+
458
+ **Performance and Stability Configuration**
459
+ - `RETRY_429_ENABLED`: Enable 429 error automatic retry (default: true)
460
+ - `RETRY_429_MAX_RETRIES`: Maximum retry attempts for 429 errors (default: 3)
461
+ - `RETRY_429_INTERVAL`: Retry interval for 429 errors, in seconds (default: 1.0)
462
+ - `ANTI_TRUNCATION_MAX_ATTEMPTS`: Maximum retry attempts for anti-truncation (default: 3)
463
+
464
+ **Network and Proxy Configuration**
465
+ - `PROXY`: HTTP/HTTPS proxy address (format: `http://host:port`)
466
+ - `OAUTH_PROXY_URL`: OAuth authentication proxy endpoint
467
+ - `GOOGLEAPIS_PROXY_URL`: Google APIs proxy endpoint
468
+ - `METADATA_SERVICE_URL`: Metadata service proxy endpoint
469
+
470
+ **Automation Configuration**
471
+ - `AUTO_BAN`: Enable automatic credential banning (default: true)
472
+ - `AUTO_LOAD_ENV_CREDS`: Automatically load environment variable credentials at startup (default: false)
473
+
474
+ **Compatibility Configuration**
475
+ - `COMPATIBILITY_MODE`: Enable compatibility mode, converts system messages to user messages (default: false)
476
+
477
+ **Logging Configuration**
478
+ - `LOG_LEVEL`: Log level (DEBUG/INFO/WARNING/ERROR, default: INFO)
479
+ - `LOG_FILE`: Log file path (default: log.txt)
480
+
481
+ **Storage Configuration**
482
+
483
+ **SQLite Configuration (Default)**
484
+ - No configuration required, automatically uses local SQLite database
485
+ - Database files are automatically created in the project directory
486
+
487
+ **MongoDB Configuration (Optional Cloud Storage)**
488
+ - `MONGODB_URI`: MongoDB connection string (enables MongoDB mode when set)
489
+ - `MONGODB_DATABASE`: MongoDB database name (default: gcli2api)
490
+
491
+ **Docker Usage Example**
492
+ ```bash
493
+ # Using universal password
494
+ docker run -d --name gcli2api \
495
+ -e PASSWORD=mypassword \
496
+ -e PORT=7861 \
497
+ ghcr.io/su-kaka/gcli2api:latest
498
+
499
+ # Using separate passwords
500
+ docker run -d --name gcli2api \
501
+ -e API_PASSWORD=my_api_password \
502
+ -e PANEL_PASSWORD=my_panel_password \
503
+ -e PORT=7861 \
504
+ ghcr.io/su-kaka/gcli2api:latest
505
+ ```
506
+
507
+ Note: When credential environment variables are set, the system will prioritize using credentials from environment variables and ignore files in the `creds` directory.
508
+
509
+ ### API Usage Methods
510
+
511
+ This service supports multiple complete sets of API endpoints:
512
+
513
+ #### 1. OpenAI Compatible Endpoints (GCLI)
514
+
515
+ **Endpoint:** `/v1/chat/completions`
516
+ **Authentication:** `Authorization: Bearer your_api_password`
517
+
518
+ Supports two request formats with automatic detection and processing:
519
+
520
+ **OpenAI Format:**
521
+ ```json
522
+ {
523
+ "model": "gemini-2.5-pro",
524
+ "messages": [
525
+ {"role": "system", "content": "You are a helpful assistant"},
526
+ {"role": "user", "content": "Hello"}
527
+ ],
528
+ "temperature": 0.7,
529
+ "stream": true
530
+ }
531
+ ```
532
+
533
+ **Gemini Native Format:**
534
+ ```json
535
+ {
536
+ "model": "gemini-2.5-pro",
537
+ "contents": [
538
+ {"role": "user", "parts": [{"text": "Hello"}]}
539
+ ],
540
+ "systemInstruction": {"parts": [{"text": "You are a helpful assistant"}]},
541
+ "generationConfig": {
542
+ "temperature": 0.7
543
+ }
544
+ }
545
+ ```
546
+
547
+ #### 2. Gemini Native Endpoints (GCLI)
548
+
549
+ **Non-streaming Endpoint:** `/v1/models/{model}:generateContent`
550
+ **Streaming Endpoint:** `/v1/models/{model}:streamGenerateContent`
551
+ **Model List:** `/v1/models`
552
+
553
+ **Authentication Methods (choose one):**
554
+ - `Authorization: Bearer your_api_password`
555
+ - `x-goog-api-key: your_api_password`
556
+ - URL parameter: `?key=your_api_password`
557
+
558
+ **Request Examples:**
559
+ ```bash
560
+ # Using x-goog-api-key header
561
+ curl -X POST "http://127.0.0.1:7861/v1/models/gemini-2.5-pro:generateContent" \
562
+ -H "x-goog-api-key: your_api_password" \
563
+ -H "Content-Type: application/json" \
564
+ -d '{
565
+ "contents": [
566
+ {"role": "user", "parts": [{"text": "Hello"}]}
567
+ ]
568
+ }'
569
+
570
+ # Using URL parameter
571
+ curl -X POST "http://127.0.0.1:7861/v1/models/gemini-2.5-pro:streamGenerateContent?key=your_api_password" \
572
+ -H "Content-Type: application/json" \
573
+ -d '{
574
+ "contents": [
575
+ {"role": "user", "parts": [{"text": "Hello"}]}
576
+ ]
577
+ }'
578
+ ```
579
+
580
+ #### 3. Claude API Format Endpoints
581
+
582
+ **Endpoint:** `/v1/messages`
583
+ **Authentication:** `x-api-key: your_api_password` or `Authorization: Bearer your_api_password`
584
+
585
+ **Request Example:**
586
+ ```bash
587
+ curl -X POST "http://127.0.0.1:7861/v1/messages" \
588
+ -H "x-api-key: your_api_password" \
589
+ -H "anthropic-version: 2023-06-01" \
590
+ -H "Content-Type: application/json" \
591
+ -d '{
592
+ "model": "gemini-2.5-pro",
593
+ "max_tokens": 1024,
594
+ "messages": [
595
+ {"role": "user", "content": "Hello, Claude!"}
596
+ ]
597
+ }'
598
+ ```
599
+
600
+ **Support for system parameter:**
601
+ ```json
602
+ {
603
+ "model": "gemini-2.5-pro",
604
+ "max_tokens": 1024,
605
+ "system": "You are a helpful assistant",
606
+ "messages": [
607
+ {"role": "user", "content": "Hello"}
608
+ ]
609
+ }
610
+ ```
611
+
612
+ **Notes:**
613
+ - Fully compatible with Claude API format specification
614
+ - Automatically converts to Gemini format for backend calls
615
+ - Supports all Claude standard parameters
616
+ - Response format follows Claude API specification
617
+
618
+ #### 4. Antigravity API Endpoints
619
+
620
+ **Supports three formats: OpenAI, Gemini, and Claude**
621
+
622
+ ##### Antigravity OpenAI Format Endpoints
623
+
624
+ **Endpoint:** `/antigravity/v1/chat/completions`
625
+ **Authentication:** `Authorization: Bearer your_api_password`
626
+
627
+ **Request Example:**
628
+ ```bash
629
+ curl -X POST "http://127.0.0.1:7861/antigravity/v1/chat/completions" \
630
+ -H "Authorization: Bearer your_api_password" \
631
+ -H "Content-Type: application/json" \
632
+ -d '{
633
+ "model": "claude-sonnet-4-5",
634
+ "messages": [
635
+ {"role": "user", "content": "Hello"}
636
+ ],
637
+ "stream": true
638
+ }'
639
+ ```
640
+
641
+ ##### Antigravity Gemini Format Endpoints
642
+
643
+ **Non-streaming Endpoint:** `/antigravity/v1/models/{model}:generateContent`
644
+ **Streaming Endpoint:** `/antigravity/v1/models/{model}:streamGenerateContent`
645
+
646
+ **Authentication Methods (choose one):**
647
+ - `Authorization: Bearer your_api_password`
648
+ - `x-goog-api-key: your_api_password`
649
+ - URL parameter: `?key=your_api_password`
650
+
651
+ **Request Examples:**
652
+ ```bash
653
+ # Gemini format non-streaming request
654
+ curl -X POST "http://127.0.0.1:7861/antigravity/v1/models/claude-sonnet-4-5:generateContent" \
655
+ -H "x-goog-api-key: your_api_password" \
656
+ -H "Content-Type: application/json" \
657
+ -d '{
658
+ "contents": [
659
+ {"role": "user", "parts": [{"text": "Hello"}]}
660
+ ],
661
+ "generationConfig": {
662
+ "temperature": 0.7
663
+ }
664
+ }'
665
+
666
+ # Gemini format streaming request
667
+ curl -X POST "http://127.0.0.1:7861/antigravity/v1/models/gemini-2.5-flash:streamGenerateContent?key=your_api_password" \
668
+ -H "Content-Type: application/json" \
669
+ -d '{
670
+ "contents": [
671
+ {"role": "user", "parts": [{"text": "Hello"}]}
672
+ ]
673
+ }'
674
+ ```
675
+
676
+ ##### Antigravity Claude Format Endpoints
677
+
678
+ **Endpoint:** `/antigravity/v1/messages`
679
+ **Authentication:** `x-api-key: your_api_password`
680
+
681
+ **Request Example:**
682
+ ```bash
683
+ curl -X POST "http://127.0.0.1:7861/antigravity/v1/messages" \
684
+ -H "x-api-key: your_api_password" \
685
+ -H "anthropic-version: 2023-06-01" \
686
+ -H "Content-Type: application/json" \
687
+ -d '{
688
+ "model": "claude-sonnet-4-5",
689
+ "max_tokens": 1024,
690
+ "messages": [
691
+ {"role": "user", "content": "Hello"}
692
+ ]
693
+ }'
694
+ ```
695
+
696
+ **Supported Antigravity Models:**
697
+ - Claude series: `claude-sonnet-4-5`, `claude-opus-4-5`, etc.
698
+ - Gemini series: `gemini-2.5-flash`, `gemini-2.5-pro`, etc.
699
+ - Automatically supports thinking models
700
+
701
+ **Gemini Native Example:**
702
+ ```python
703
+ from io import BytesIO
704
+ from PIL import Image
705
+ from google.genai import Client
706
+ from google.genai.types import HttpOptions
707
+ from google.genai import types
708
+ # The client gets the API key from the environment variable `GEMINI_API_KEY`.
709
+
710
+ client = Client(
711
+ api_key="pwd",
712
+ http_options=HttpOptions(base_url="http://127.0.0.1:7861"),
713
+ )
714
+
715
+ prompt = (
716
+ """
717
+ Draw a cat
718
+ """
719
+ )
720
+
721
+ response = client.models.generate_content(
722
+ model="gemini-3.1-flash-image",
723
+ contents=[prompt],
724
+ config=types.GenerateContentConfig(
725
+ image_config=types.ImageConfig(
726
+ aspect_ratio="16:9",
727
+ )
728
+ )
729
+ )
730
+ for part in response.candidates[0].content.parts:
731
+ if part.text is not None:
732
+ print(part.text)
733
+ elif part.inline_data is not None:
734
+ image = Image.open(BytesIO(part.inline_data.data))
735
+ image.save("generated_image.png")
736
+
737
+ ```
738
+
739
+ **Notes:**
740
+ - OpenAI endpoints return OpenAI-compatible format
741
+ - Gemini endpoints return Gemini native format
742
+ - Both endpoints use the same API password
743
+
744
+ ## 📋 Complete API Reference
745
+
746
+ ### Web Console API
747
+
748
+ **Authentication Endpoints**
749
+ - `POST /auth/login` - User login
750
+ - `POST /auth/start` - Start OAuth authentication (supports GCLI and Antigravity modes)
751
+ - `POST /auth/callback` - Handle OAuth callback
752
+ - `POST /auth/callback-url` - Complete authentication directly from callback URL
753
+ - `GET /auth/status/{project_id}` - Check authentication status
754
+
755
+ **Credential Management Endpoints** (supports `mode=geminicli` or `mode=antigravity` parameter)
756
+ - `POST /creds/upload` - Batch upload credential files (supports JSON and ZIP)
757
+ - `GET /creds/status` - Get credential status list (supports pagination and filtering)
758
+ - `GET /creds/detail/{filename}` - Get single credential details
759
+ - `POST /creds/action` - Single credential operation (enable/disable/delete)
760
+ - `POST /creds/batch-action` - Batch credential operations
761
+ - `GET /creds/download/{filename}` - Download single credential file
762
+ - `GET /creds/download-all` - Package download all credentials
763
+ - `POST /creds/fetch-email/{filename}` - Get user email
764
+ - `POST /creds/refresh-all-emails` - Batch refresh user emails
765
+ - `POST /creds/deduplicate-by-email` - Deduplicate credentials by email
766
+ - `POST /creds/verify-project/{filename}` - Verify credential Project ID
767
+ - `GET /creds/quota/{filename}` - Get credential quota information (Antigravity only)
768
+
769
+ **Configuration Management Endpoints**
770
+ - `GET /config/get` - Get current configuration
771
+ - `POST /config/save` - Save configuration
772
+
773
+ **Log Management Endpoints**
774
+ - `POST /logs/clear` - Clear logs
775
+ - `GET /logs/download` - Download log file
776
+ - `WebSocket /logs/stream` - Real-time log stream
777
+
778
+ **Version Information Endpoints**
779
+ - `GET /version/info` - Get version information (optional `check_update=true` parameter to check for updates)
780
+
781
+ ### Chat API Features
782
+
783
+ **Multimodal Support**
784
+ ```json
785
+ {
786
+ "model": "gemini-2.5-pro",
787
+ "messages": [
788
+ {
789
+ "role": "user",
790
+ "content": [
791
+ {"type": "text", "text": "Describe this image"},
792
+ {
793
+ "type": "image_url",
794
+ "image_url": {
795
+ "url": "data:image/jpeg;base64,/9j/4AAQSkZJRgABA..."
796
+ }
797
+ }
798
+ ]
799
+ }
800
+ ]
801
+ }
802
+ ```
803
+
804
+ **Thinking Mode Support**
805
+ ```json
806
+ {
807
+ "model": "gemini-2.5-pro-high",
808
+ "messages": [
809
+ {"role": "user", "content": "Complex math problem"}
810
+ ]
811
+ }
812
+ ```
813
+
814
+ Response will include separated thinking content:
815
+ ```json
816
+ {
817
+ "choices": [{
818
+ "message": {
819
+ "role": "assistant",
820
+ "content": "Final answer",
821
+ "reasoning_content": "Detailed thought process..."
822
+ }
823
+ }]
824
+ }
825
+ ```
826
+
827
+ **Streaming Anti-truncation Usage**
828
+ ```json
829
+ {
830
+ "model": "流式抗截断/gemini-2.5-pro",
831
+ "messages": [
832
+ {"role": "user", "content": "Write a long article"}
833
+ ],
834
+ "stream": true
835
+ }
836
+ ```
837
+
838
+ **Compatibility Mode**
839
+ ```bash
840
+ # Enable compatibility mode
841
+ export COMPATIBILITY_MODE=true
842
+ ```
843
+ In this mode, all `system` messages are converted to `user` messages, improving compatibility with certain clients.
844
+
845
+ ---
846
+
847
+ ## 💬 Community
848
+
849
+ Welcome to join the QQ group for discussion!
850
+
851
+ **QQ Group: 1083250744**
852
+
853
+ <img src="qq群.jpg" width="200" alt="QQ Group QR Code">
854
+
855
+ ---
856
+
857
+ ## License and Disclaimer
858
+
859
+ This project is for learning and research purposes only. Using this project indicates that you agree to:
860
+ - Not use this project for any commercial purposes
861
+ - Bear all risks and responsibilities of using this project
862
+ - Comply with relevant terms of service and legal regulations
863
+
864
+ The project authors are not responsible for any direct or indirect losses arising from the use of this project.
865
+
866
+ ## ⚠️ License Declaration
867
+
868
+ **This project is licensed under the Cooperative Non-Commercial License (CNC-1.0)**
869
+
870
+ This is a strict anti-commercial open source license. Please refer to the [LICENSE](../LICENSE) file for details.
871
+
872
+ ### ✅ Permitted Uses:
873
+ - Personal learning, research, and educational purposes
874
+ - Non-profit organization use
875
+ - Open source project integration (must comply with the same license)
876
+ - Academic research and publication
877
+
878
+ ### ❌ Prohibited Uses:
879
+ - Any form of commercial use
880
+ - Enterprise use with annual revenue exceeding $1 million
881
+ - Venture capital-backed or publicly traded companies
882
+ - Providing paid services or products
883
+ - Commercial competitive use
docs/README_JA.md ADDED
@@ -0,0 +1,883 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # GeminiCLI to API
2
+
3
+ **GeminiCLIおよびAntigravityをOpenAI、GEMINI、Claude API互換インターフェースに変換**
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](README_EN.md) | 日本語
10
+
11
+ ## 🚀 クイックデプロイ
12
+
13
+ [![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/templates/97VMEF?referralCode=sukaka)
14
+ [![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo=https://github.com/su-kaka/gcli2api)
15
+ ---
16
+
17
+ ## インストールガイド
18
+
19
+ ### Termux環境
20
+
21
+ **初期インストール**
22
+ ```bash
23
+ 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
24
+ ```
25
+
26
+ **サービス再起動**
27
+ ```bash
28
+ cd gcli2api
29
+ bash termux-start.sh
30
+ ```
31
+
32
+ ### Windows環境
33
+
34
+ **初期インストール**
35
+ ```powershell
36
+ iex (iwr "https://raw.githubusercontent.com/su-kaka/gcli2api/refs/heads/master/install.ps1" -UseBasicParsing).Content
37
+ ```
38
+
39
+ **サービス再起動**
40
+ `start.bat` をダブルクリックして実行
41
+
42
+ ### Linux環境
43
+
44
+ **初期インストール**
45
+ ```bash
46
+ curl -o install.sh "https://raw.githubusercontent.com/su-kaka/gcli2api/refs/heads/master/install.sh" && chmod +x install.sh && ./install.sh
47
+ ```
48
+
49
+ **サービス再起動**
50
+ ```bash
51
+ cd gcli2api
52
+ bash start.sh
53
+ ```
54
+
55
+ ### macOS環境
56
+
57
+ **初期インストール**
58
+ ```bash
59
+ curl -o darwin-install.sh "https://raw.githubusercontent.com/su-kaka/gcli2api/refs/heads/master/darwin-install.sh" && chmod +x darwin-install.sh && ./darwin-install.sh
60
+ ```
61
+
62
+ **サービス再起動**
63
+ ```bash
64
+ cd gcli2api
65
+ bash start.sh
66
+ ```
67
+
68
+ ### Docker環境
69
+
70
+ **Docker Runコマンド**
71
+ ```bash
72
+ # 共通パスワードを使用
73
+ 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
74
+
75
+ # 個別パスワードを使用
76
+ 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
77
+ ```
78
+
79
+ **Docker Mac**
80
+ ```bash
81
+ # 共通パスワードを使用
82
+ docker run -d \
83
+ --name gcli2api \
84
+ -p 7861:7861 \
85
+ -p 8080:8080 \
86
+ -e PASSWORD=pwd \
87
+ -e PORT=7861 \
88
+ -v "$(pwd)/data/creds":/app/creds \
89
+ ghcr.io/su-kaka/gcli2api:latest
90
+ ```
91
+
92
+ ```bash
93
+ # 個別パスワードを使用
94
+ docker run -d \
95
+ --name gcli2api \
96
+ -p 7861:7861 \
97
+ -p 8080:8080 \
98
+ -e API_PASSWORD=api_pwd \
99
+ -e PANEL_PASSWORD=panel_pwd \
100
+ -e PORT=7861 \
101
+ -v $(pwd)/data/creds:/app/creds \
102
+ ghcr.io/su-kaka/gcli2api:latest
103
+ ```
104
+
105
+ **Docker Compose Runコマンド**
106
+ 1. 以下の内容を `docker-compose.yml` ファイルとして保存:
107
+ ```yaml
108
+ version: '3.8'
109
+
110
+ services:
111
+ gcli2api:
112
+ image: ghcr.io/su-kaka/gcli2api:latest
113
+ container_name: gcli2api
114
+ restart: unless-stopped
115
+ network_mode: host
116
+ environment:
117
+ # 共通パスワードを使用(シンプルなデプロイに推奨)
118
+ - PASSWORD=pwd
119
+ - PORT=7861
120
+ # または個別パスワードを使用(本番環境に推奨)
121
+ # - API_PASSWORD=your_api_password
122
+ # - PANEL_PASSWORD=your_panel_password
123
+ volumes:
124
+ - ./data/creds:/app/creds
125
+ healthcheck:
126
+ 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)\""]
127
+ interval: 30s
128
+ timeout: 10s
129
+ retries: 3
130
+ start_period: 40s
131
+ ```
132
+ 2. サービスを起動:
133
+ ```bash
134
+ docker-compose up -d
135
+ ```
136
+
137
+ ## コア機能
138
+
139
+ ### 🔄 APIエンドポイントとフォーマット対応
140
+
141
+ **マルチエンドポイント・マルチフォーマット対応**
142
+ - **OpenAI互換エンドポイント**: `/v1/chat/completions` および `/v1/models`
143
+ - 標準OpenAIフォーマット(messages構造)に対応
144
+ - Geminiネイティブフォーマット(contents構造)に対応
145
+ - フォーマットの自動検出・変換、手動切替不要
146
+ - マルチモーダル入力に対応(テキスト+画像)
147
+ - **Geminiネイティブエンドポイント**: `/v1/models/{model}:generateContent` および `streamGenerateContent`
148
+ - Geminiネイティブ API仕様に完全対応
149
+ - 複数の認証方式: Bearer Token、x-goog-api-keyヘッダー、URLパラメータkey
150
+ - **Claudeフォーマット互換**: Claude APIフォーマットに完全��応
151
+ - エンドポイント: `/v1/messages`(Claude API仕様に準拠)
152
+ - Claude標準messagesフォーマットに対応
153
+ - systemパラメータおよびClaude固有機能に対応
154
+ - バックエンド対応フォーマットへの自動変換
155
+ - **Antigravity API対応**: OpenAI、Gemini、Claudeフォーマットに対応
156
+ - OpenAIフォーマットエンドポイント: `/antigravity/v1/chat/completions`
157
+ - Geminiフォーマットエンドポイント: `/antigravity/v1/models/{model}:generateContent` および `streamGenerateContent`
158
+ - Claudeフォーマットエンドポイント: `/antigravity/v1/messages`
159
+ - 全Antigravityモデルに対応(Claude、Geminiなど)
160
+ - モデル名の自動マッピングおよびThinkingモード検出
161
+
162
+ ### 🔐 認証とセキュリティ管理
163
+
164
+ **柔軟なパスワード管理**
165
+ - **個別パスワード対応**: APIパスワード(チャットエンドポイント)とコントロールパネルパスワードを個別に設定可能
166
+ - **複数の認証方式**: Authorization Bearer、x-goog-api-keyヘッダー、URLパラメータなどに対応
167
+ - **JWTトークン認証**: コントロールパネルはJWTトークン認証に対応
168
+ - **ユーザーメール取得**: Googleアカウントのメールアドレスを自動取得・表示
169
+
170
+ ### 📊 インテリジェントなクレデンシャル管理システム
171
+
172
+ **高度なクレデンシャル管理**
173
+ - 複数のGoogle OAuthクレデンシャルの自動ローテーション
174
+ - 冗長認証による安定性の向上
175
+ - ロードバランシングと同時リクエスト対応
176
+ - 自動障害検出とクレデンシャル無効化
177
+ - クレデンシャル使用統計とクォータ管理
178
+ - クレデンシャルファイルの手動有効化/無効化に対応
179
+ - クレデンシャルファイルの一括操作(有効化、無効化、削除)
180
+
181
+ **クレデンシャルステータス監視**
182
+ - リアルタイムのクレデンシャルヘルスチェック
183
+ - エラーコードの追跡(429、403、500など)
184
+ - 自動BAN機能(設定可能)
185
+
186
+ ### 🌊 ストリーミングとレスポンス処理
187
+
188
+ **複数のストリーミング対応**
189
+ - リアルタイムストリーミングレスポンス
190
+ - 疑似ストリーミングモード(互換性向上用)
191
+ - ストリーミング途切れ防止機能(回答の途切れを防止)
192
+ - 非同期タスク管理とタイムアウト処理
193
+
194
+ **レスポンス最適化**
195
+ - 思考チェーン内容の分離
196
+ - 推論プロセス(reasoning_content)の処理
197
+ - マルチターン会話のコンテキスト管理
198
+ - 互換モード(systemメッセージをuserメッセージに変換)
199
+
200
+ ### 🎛️ Web管理コンソール
201
+
202
+ **フル機能のWebインターフェース**
203
+ - OAuth認証フロー管理(GCLIおよびAntigravityデュアルモード対応)
204
+ - クレデンシャルファイルのアップロード、ダウンロード、管理
205
+ - リアルタイムログ表示(WebSocket)
206
+ - システム設定管理
207
+ - 使用統計と監視ダッシュボード
208
+ - モバイル対応インターフェース
209
+
210
+ **一括操作対応**
211
+ - ZIPファイルによるクレデンシャル一括アップロード(GCLIおよびAntigravity)
212
+ - クレデンシャルの一括有効化/無効化/削除
213
+ - ユーザーメールの一括取得
214
+ - 設定の一括管理
215
+ - 全クレデンシャルタイプ統合一括アップロードインターフェース
216
+
217
+ ### 📈 使用状況モニタリング
218
+
219
+ **リアルタイム監視**
220
+ - WebSocketリアルタイムログストリーム
221
+ - システムステータス監視
222
+ - クレデンシャルヘルスステータス
223
+
224
+ ### 🔧 高度な設定とカスタマイズ
225
+
226
+ **ネットワークとプロキシ設定**
227
+ - HTTP/HTTPSプロキシ対応
228
+ - プロキシエンドポイント設定(OAuth、Google APIs、メタデータサービス)
229
+ - タイムアウトとリトライ設定
230
+ - ネットワークエラー処理とリカバリ
231
+
232
+ **パフォーマンスと安定性の設定**
233
+ - 429エラーの自動リトライ(間隔と回数を設定可能)
234
+ - 途切れ防止の最大リトライ回数
235
+
236
+ **ログとデバッグ**
237
+ - マルチレベルログシステム(DEBUG、INFO、WARNING、ERROR)
238
+ - ログファイル管理
239
+ - リアルタイムログストリーム
240
+ - ログのダウンロードとクリア
241
+
242
+ ### 🔄 環境変数と設定管理
243
+
244
+ **柔軟な設定方法**
245
+ - 環境変数による設定
246
+ - ホット設定更新(一部設定項目)
247
+ - 設定ロック(環境変数優先)
248
+
249
+ ## 対応モデル
250
+
251
+ 全モデルが100万トークンのコンテキストウィンドウに対応。各クレデンシャルファイルで1000リクエストのクォータを提供。
252
+
253
+ ### 🤖 基本モデル
254
+ - `gemini-2.5-pro`
255
+ - `gemini-3-pro-preview`
256
+ - `gemini-3.1-pro-preview`
257
+
258
+ ### 🧠 Thinkingモデル
259
+ - `gemini-2.5-pro-high`: Thinkingモード
260
+ - `gemini-2.5-pro-low`: 低Thinkingモード
261
+ - カスタムThinkingバジェット設定に対応
262
+ - 思考内���と最終回答の自動分離
263
+
264
+ ### 🔍 検索拡張モデル
265
+ - `gemini-2.5-pro-search`: 検索機能統合モデル
266
+
267
+ ### 🖼️ 画像生成モデル(Antigravity)
268
+ - `gemini-3.1-flash-image`: 基本画像生成モデル
269
+ - **解像度サフィックス**:
270
+ - `-2k`: 2K解像度
271
+ - `-4k`: 4K HD解像度
272
+ - **アスペクト比サフィックス**:
273
+ - `-1x1`: 正方形(アバター)
274
+ - `-16x9`: 横長(デスクトップ壁紙)
275
+ - `-9x16`: 縦長(モバイル壁紙)
276
+ - `-21x9`: ウルトラワイド(ウルトラワイドモニター)
277
+ - `-4x3`: 従来のディスプレイ
278
+ - `-3x4`: 縦型ポスター
279
+ - **組み合わせ例**:
280
+ - `gemini-3.1-flash-image-4k-16x9`: 4K横長
281
+ - `gemini-3.1-flash-image-2k-9x16`: 2K縦長
282
+ - 比率未指定時はAPIが自動的にアスペクト比を決定
283
+
284
+ ### 🌊 特殊機能バリアント
285
+ - **疑似ストリーミングモード**: 任意のモデル名に `-假流式` サフィックスを追加
286
+ - 例: `gemini-2.5-pro-假流式`
287
+ - ストリーミングレスポンスが必要だがサーバーが真のストリーミングに対応していない場合に使用
288
+ - **ストリーミング途切れ防止モード**: モデル名に `流式抗截断/` プレフィックスを追加
289
+ - 例: `流式抗截断/gemini-2.5-pro`
290
+ - レスポンスの途切れを自動検出しリトライして完全な回答を保証
291
+
292
+ ### 🔧 モデル機能の自動検出
293
+ - システムがモデル名内の機能識別子を自動認識
294
+ - 機能モード切替を透過的に処理
295
+ - 機能の組み合わせ使用に対応
296
+
297
+ ---
298
+
299
+ ## 設定手順
300
+
301
+ 1. `http://127.0.0.1:7861` にアクセス(デフォルトポート、PORT環境変数で変更可能)
302
+ 2. OAuth認証フローを完了(デフォルトパスワード: `pwd`、環境変数で変更可能)
303
+ - **GCLIモード**: Google Cloud Gemini APIクレデンシャルの取得用
304
+ - **Antigravityモード**: Google Antigravity APIクレデンシャルの取得用
305
+ 3. クライアントを設定:
306
+
307
+ **OpenAI互換クライアント:**
308
+ - **エンドポイントアドレス**: `http://127.0.0.1:7861/v1`
309
+ - **APIキー**: `pwd`(デフォルト値、API_PASSWORDまたはPASSWORD環境変数で変更可能)
310
+
311
+ **Geminiネイティブクライアント:**
312
+ - **エンドポイントアドレス**: `http://127.0.0.1:7861`
313
+ - **認証方式**:
314
+ - `Authorization: Bearer your_api_password`
315
+ - `x-goog-api-key: your_api_password`
316
+ - URLパラメータ: `?key=your_api_password`
317
+
318
+ ### 🌟 デュアル認証モード対応
319
+
320
+ **GCLI認証モード**
321
+ - 標準Google Cloud Gemini API認証
322
+ - OAuth2.0認証フローに対応
323
+ - 必要なGoogle Cloud APIを自動的に有効化
324
+
325
+ **Antigravity認証モード**
326
+ - Google Antigravity API専用認証
327
+ - 独立したクレデンシャル管理システム
328
+ - 一括アップロードと管理に対応
329
+ - GCLIクレデンシャルとは完全に分離
330
+
331
+ **統合管理インターフェース**
332
+ - 「一括アップロード」タブで両方のクレデンシャルタイプを管理
333
+ - 上部セクション: GCLIクレデンシャル一括アップロード(青テーマ)
334
+ - 下部セクション: Antigravityクレデンシャル一括アップロード(緑テーマ)
335
+ - 各タイプ別のクレデンシャル管理タブ
336
+
337
+ ## 💾 データストレージモード
338
+
339
+ ### 🌟 ストレージバックエンド対応
340
+
341
+ gcli2apiは2つのストレージバックエンドに対応: **ローカルSQLite(デフォルト)** と **MongoDB(クラウド分散ストレージ)**
342
+
343
+ ### 📁 ローカルSQLiteストレージ(デフォルト)
344
+
345
+ **デフォルトストレージ方式**
346
+ - 設定不要、すぐに利用可能
347
+ - データはローカルSQLiteデータベースに保存
348
+ - 単一マシンデプロイおよび個人利用に最適
349
+ - データベースファイルの自動作成・管理
350
+
351
+ ### 🍃 MongoDBクラウドストレージモード
352
+
353
+ **クラウド分散ストレージソリューション**
354
+
355
+ マルチインスタンスデプロイやクラウドストレージが必要な場合、MongoDBストレージモードを有効にできます。
356
+
357
+ ### ⚙️ MongoDBモードの有効化
358
+
359
+ **ステップ1: MongoDB接続の設定**
360
+ ```bash
361
+ # ローカルMongoDB
362
+ export MONGODB_URI="mongodb://localhost:27017"
363
+
364
+ # MongoDB Atlasクラウドサービス
365
+ export MONGODB_URI="mongodb+srv://username:password@cluster.mongodb.net"
366
+
367
+ # 認証付きMongoDB
368
+ export MONGODB_URI="mongodb://admin:password@localhost:27017/admin"
369
+
370
+ # オプション: カスタムデータベース名(デフォルト: gcli2api)
371
+ export MONGODB_DATABASE="my_gcli_db"
372
+ ```
373
+
374
+ **ステップ2: アプリケーションの起動**
375
+ ```bash
376
+ # アプリケーションがMongoDB設定を自動検出し、MongoDBストレージを使用します
377
+ python web.py
378
+ ```
379
+
380
+ **Docker環境でのMongoDB使用**
381
+ ```bash
382
+ # 単一MongoDBデプロイ
383
+ docker run -d --name gcli2api \
384
+ -e MONGODB_URI="mongodb://mongodb:27017" \
385
+ -e API_PASSWORD=your_password \
386
+ --network your_network \
387
+ ghcr.io/su-kaka/gcli2api:latest
388
+
389
+ # MongoDB Atlasの使用
390
+ docker run -d --name gcli2api \
391
+ -e MONGODB_URI="mongodb+srv://user:pass@cluster.mongodb.net/gcli2api" \
392
+ -e API_PASSWORD=your_password \
393
+ -p 7861:7861 \
394
+ ghcr.io/su-kaka/gcli2api:latest
395
+ ```
396
+
397
+ **Docker Composeの例**
398
+ ```yaml
399
+ version: '3.8'
400
+
401
+ services:
402
+ mongodb:
403
+ image: mongo:7
404
+ container_name: gcli2api-mongodb
405
+ restart: unless-stopped
406
+ environment:
407
+ MONGO_INITDB_ROOT_USERNAME: admin
408
+ MONGO_INITDB_ROOT_PASSWORD: password123
409
+ volumes:
410
+ - mongodb_data:/data/db
411
+ ports:
412
+ - "27017:27017"
413
+
414
+ gcli2api:
415
+ image: ghcr.io/su-kaka/gcli2api:latest
416
+ container_name: gcli2api
417
+ restart: unless-stopped
418
+ depends_on:
419
+ - mongodb
420
+ environment:
421
+ - MONGODB_URI=mongodb://admin:password123@mongodb:27017/admin
422
+ - MONGODB_DATABASE=gcli2api
423
+ - API_PASSWORD=your_api_password
424
+ - PORT=7861
425
+ ports:
426
+ - "7861:7861"
427
+
428
+ volumes:
429
+ mongodb_data:
430
+ ```
431
+
432
+
433
+ ### 🔧 高度な設定
434
+
435
+ **MongoDB接続の最適化**
436
+ ```bash
437
+ # コネクションプールとタイムアウト設定
438
+ export MONGODB_URI="mongodb://localhost:27017?maxPoolSize=10&serverSelectionTimeoutMS=5000"
439
+
440
+ # レプリカセット設定
441
+ export MONGODB_URI="mongodb://host1:27017,host2:27017,host3:27017/gcli2api?replicaSet=myReplicaSet"
442
+
443
+ # リード・ライト分離設定
444
+ export MONGODB_URI="mongodb://localhost:27017/gcli2api?readPreference=secondaryPreferred"
445
+ ```
446
+
447
+ ### 環境変数設定
448
+
449
+ **基本設定**
450
+ - `PORT`: サービスポート(デフォルト: 7861)
451
+ - `HOST`: サーバーリッスンアドレス(デフォルト: 0.0.0.0)
452
+
453
+ **パスワード設定**
454
+ - `API_PASSWORD`: チャットAPIアクセスパスワード(デフォルト: PASSWORDまたはpwdを継承)
455
+ - `PANEL_PASSWORD`: コントロールパネルアクセスパスワード(デフォルト: PASSWORDまたはpwdを継承)
456
+ - `PASSWORD`: 共通パスワード、設定時に上記2つを上書き(デフォルト: pwd)
457
+
458
+ **パフォーマンスと安定性の設定**
459
+ - `RETRY_429_ENABLED`: 429エラー自動リトライの有効化(デフォルト: true)
460
+ - `RETRY_429_MAX_RETRIES`: 429エラーの最大リトライ回数(デフォルト: 3)
461
+ - `RETRY_429_INTERVAL`: 429エラーのリトライ間隔、秒単位(デフォルト: 1.0)
462
+ - `ANTI_TRUNCATION_MAX_ATTEMPTS`: 途切れ防止の最大リトライ回数(デフォルト: 3)
463
+
464
+ **ネットワークとプロキシ設定**
465
+ - `PROXY`: HTTP/HTTPSプロキシアドレス(形式: `http://host:port`)
466
+ - `OAUTH_PROXY_URL`: OAuth認証プロキシエンドポイント
467
+ - `GOOGLEAPIS_PROXY_URL`: Google APIsプロキシエンドポイント
468
+ - `METADATA_SERVICE_URL`: メタデータサービスプロキシエンドポイント
469
+
470
+ **自動化設定**
471
+ - `AUTO_BAN`: クレデンシャル自動BANの有効化(デフォルト: true)
472
+ - `AUTO_LOAD_ENV_CREDS`: 起動時に環境変数クレデンシャルを自動ロード(デフォルト: false)
473
+
474
+ **互換性設定**
475
+ - `COMPATIBILITY_MODE`: 互換モードの有効化、systemメッセージをuserメッセージに変換(デフォルト: false)
476
+
477
+ **ログ設定**
478
+ - `LOG_LEVEL`: ログレベル(DEBUG/INFO/WARNING/ERROR、デフォルト: INFO)
479
+ - `LOG_FILE`: ログファイルパス(デフォルト: log.txt)
480
+
481
+ **ストレージ設定**
482
+
483
+ **SQLite設定(デフォルト)**
484
+ - 設定不要、自動的にローカルSQLiteデータベースを使用
485
+ - データベースファイルはプロジェクトディレクトリに自動作成
486
+
487
+ **MongoDB設定(オプションのクラウドストレージ)**
488
+ - `MONGODB_URI`: MongoDB接続文字列(設定時にMongoDBモードを有効化)
489
+ - `MONGODB_DATABASE`: MongoDBデータベース名(デフォルト: gcli2api)
490
+
491
+ **Docker使用例**
492
+ ```bash
493
+ # 共通パスワードを使用
494
+ docker run -d --name gcli2api \
495
+ -e PASSWORD=mypassword \
496
+ -e PORT=7861 \
497
+ ghcr.io/su-kaka/gcli2api:latest
498
+
499
+ # 個別パスワードを使用
500
+ docker run -d --name gcli2api \
501
+ -e API_PASSWORD=my_api_password \
502
+ -e PANEL_PASSWORD=my_panel_password \
503
+ -e PORT=7861 \
504
+ ghcr.io/su-kaka/gcli2api:latest
505
+ ```
506
+
507
+ 注意: クレデンシャル環境変数が設定されている場合、システムは環境変数のクレデンシャルを優先的に使用し、`creds` ディレクトリ内のファイルを無視します。
508
+
509
+ ### API使用方法
510
+
511
+ 本サービスは複数の完全なAPIエンドポイントセットに対応しています:
512
+
513
+ #### 1. OpenAI互換エンドポイント(GCLI)
514
+
515
+ **エンドポイント:** `/v1/chat/completions`
516
+ **認証:** `Authorization: Bearer your_api_password`
517
+
518
+ 2つのリクエストフォーマットに対応し、自動検出・処理を行います:
519
+
520
+ **OpenAIフォーマット:**
521
+ ```json
522
+ {
523
+ "model": "gemini-2.5-pro",
524
+ "messages": [
525
+ {"role": "system", "content": "You are a helpful assistant"},
526
+ {"role": "user", "content": "Hello"}
527
+ ],
528
+ "temperature": 0.7,
529
+ "stream": true
530
+ }
531
+ ```
532
+
533
+ **Geminiネイティブフォーマット:**
534
+ ```json
535
+ {
536
+ "model": "gemini-2.5-pro",
537
+ "contents": [
538
+ {"role": "user", "parts": [{"text": "Hello"}]}
539
+ ],
540
+ "systemInstruction": {"parts": [{"text": "You are a helpful assistant"}]},
541
+ "generationConfig": {
542
+ "temperature": 0.7
543
+ }
544
+ }
545
+ ```
546
+
547
+ #### 2. Geminiネイティブエンドポイント(GCLI)
548
+
549
+ **非ストリーミングエンドポイント:** `/v1/models/{model}:generateContent`
550
+ **ストリーミングエンドポイント:** `/v1/models/{model}:streamGenerateContent`
551
+ **モデル一覧:** `/v1/models`
552
+
553
+ **認証方式(いずれか1つを選択):**
554
+ - `Authorization: Bearer your_api_password`
555
+ - `x-goog-api-key: your_api_password`
556
+ - URLパラメータ: `?key=your_api_password`
557
+
558
+ **リクエスト例:**
559
+ ```bash
560
+ # x-goog-api-keyヘッダーを使用
561
+ curl -X POST "http://127.0.0.1:7861/v1/models/gemini-2.5-pro:generateContent" \
562
+ -H "x-goog-api-key: your_api_password" \
563
+ -H "Content-Type: application/json" \
564
+ -d '{
565
+ "contents": [
566
+ {"role": "user", "parts": [{"text": "Hello"}]}
567
+ ]
568
+ }'
569
+
570
+ # URLパラメータを使用
571
+ curl -X POST "http://127.0.0.1:7861/v1/models/gemini-2.5-pro:streamGenerateContent?key=your_api_password" \
572
+ -H "Content-Type: application/json" \
573
+ -d '{
574
+ "contents": [
575
+ {"role": "user", "parts": [{"text": "Hello"}]}
576
+ ]
577
+ }'
578
+ ```
579
+
580
+ #### 3. Claude APIフォーマットエンドポイント
581
+
582
+ **エンドポイント:** `/v1/messages`
583
+ **認証:** `x-api-key: your_api_password` または `Authorization: Bearer your_api_password`
584
+
585
+ **リクエスト例:**
586
+ ```bash
587
+ curl -X POST "http://127.0.0.1:7861/v1/messages" \
588
+ -H "x-api-key: your_api_password" \
589
+ -H "anthropic-version: 2023-06-01" \
590
+ -H "Content-Type: application/json" \
591
+ -d '{
592
+ "model": "gemini-2.5-pro",
593
+ "max_tokens": 1024,
594
+ "messages": [
595
+ {"role": "user", "content": "Hello, Claude!"}
596
+ ]
597
+ }'
598
+ ```
599
+
600
+ **systemパラメータの対応:**
601
+ ```json
602
+ {
603
+ "model": "gemini-2.5-pro",
604
+ "max_tokens": 1024,
605
+ "system": "You are a helpful assistant",
606
+ "messages": [
607
+ {"role": "user", "content": "Hello"}
608
+ ]
609
+ }
610
+ ```
611
+
612
+ **注意事項:**
613
+ - Claude APIフォーマット仕様に完全互換
614
+ - バックエンド呼び出し時にGeminiフォーマットへ自動変換
615
+ - すべてのClaude標準パラメータに対応
616
+ - レスポンスフォーマットはClaude API仕様に準拠
617
+
618
+ #### 4. Antigravity APIエンドポイント
619
+
620
+ **OpenAI、Gemini、Claudeの3フォーマットに対応**
621
+
622
+ ##### Antigravity OpenAIフォーマットエンドポイント
623
+
624
+ **エンドポイント:** `/antigravity/v1/chat/completions`
625
+ **認証:** `Authorization: Bearer your_api_password`
626
+
627
+ **リクエスト例:**
628
+ ```bash
629
+ curl -X POST "http://127.0.0.1:7861/antigravity/v1/chat/completions" \
630
+ -H "Authorization: Bearer your_api_password" \
631
+ -H "Content-Type: application/json" \
632
+ -d '{
633
+ "model": "claude-sonnet-4-5",
634
+ "messages": [
635
+ {"role": "user", "content": "Hello"}
636
+ ],
637
+ "stream": true
638
+ }'
639
+ ```
640
+
641
+ ##### Antigravity Geminiフォーマットエンドポイント
642
+
643
+ **非ストリーミングエンドポイント:** `/antigravity/v1/models/{model}:generateContent`
644
+ **ストリーミングエンドポイント:** `/antigravity/v1/models/{model}:streamGenerateContent`
645
+
646
+ **認証方式(いずれか1つを選択):**
647
+ - `Authorization: Bearer your_api_password`
648
+ - `x-goog-api-key: your_api_password`
649
+ - URLパラメータ: `?key=your_api_password`
650
+
651
+ **リクエスト例:**
652
+ ```bash
653
+ # Geminiフォーマット非ストリーミングリクエスト
654
+ curl -X POST "http://127.0.0.1:7861/antigravity/v1/models/claude-sonnet-4-5:generateContent" \
655
+ -H "x-goog-api-key: your_api_password" \
656
+ -H "Content-Type: application/json" \
657
+ -d '{
658
+ "contents": [
659
+ {"role": "user", "parts": [{"text": "Hello"}]}
660
+ ],
661
+ "generationConfig": {
662
+ "temperature": 0.7
663
+ }
664
+ }'
665
+
666
+ # Geminiフォーマットストリーミングリクエスト
667
+ curl -X POST "http://127.0.0.1:7861/antigravity/v1/models/gemini-2.5-flash:streamGenerateContent?key=your_api_password" \
668
+ -H "Content-Type: application/json" \
669
+ -d '{
670
+ "contents": [
671
+ {"role": "user", "parts": [{"text": "Hello"}]}
672
+ ]
673
+ }'
674
+ ```
675
+
676
+ ##### Antigravity Claudeフォーマットエンドポイント
677
+
678
+ **エンドポイント:** `/antigravity/v1/messages`
679
+ **認証:** `x-api-key: your_api_password`
680
+
681
+ **リクエスト例:**
682
+ ```bash
683
+ curl -X POST "http://127.0.0.1:7861/antigravity/v1/messages" \
684
+ -H "x-api-key: your_api_password" \
685
+ -H "anthropic-version: 2023-06-01" \
686
+ -H "Content-Type: application/json" \
687
+ -d '{
688
+ "model": "claude-sonnet-4-5",
689
+ "max_tokens": 1024,
690
+ "messages": [
691
+ {"role": "user", "content": "Hello"}
692
+ ]
693
+ }'
694
+ ```
695
+
696
+ **対応Antigravityモデル:**
697
+ - Claudeシリーズ: `claude-sonnet-4-5`、`claude-opus-4-5` など
698
+ - Geminiシリーズ: `gemini-2.5-flash`、`gemini-2.5-pro` など
699
+ - Thinkingモデルに自動対��
700
+
701
+ **Geminiネイティブの例:**
702
+ ```python
703
+ from io import BytesIO
704
+ from PIL import Image
705
+ from google.genai import Client
706
+ from google.genai.types import HttpOptions
707
+ from google.genai import types
708
+ # クライアントは環境変数 `GEMINI_API_KEY` からAPIキーを取得します。
709
+
710
+ client = Client(
711
+ api_key="pwd",
712
+ http_options=HttpOptions(base_url="http://127.0.0.1:7861"),
713
+ )
714
+
715
+ prompt = (
716
+ """
717
+ 猫を描いて
718
+ """
719
+ )
720
+
721
+ response = client.models.generate_content(
722
+ model="gemini-3.1-flash-image",
723
+ contents=[prompt],
724
+ config=types.GenerateContentConfig(
725
+ image_config=types.ImageConfig(
726
+ aspect_ratio="16:9",
727
+ )
728
+ )
729
+ )
730
+ for part in response.candidates[0].content.parts:
731
+ if part.text is not None:
732
+ print(part.text)
733
+ elif part.inline_data is not None:
734
+ image = Image.open(BytesIO(part.inline_data.data))
735
+ image.save("generated_image.png")
736
+
737
+ ```
738
+
739
+ **注意事項:**
740
+ - OpenAIエンドポイントはOpenAI互換フォーマットを返します
741
+ - GeminiエンドポイントはGeminiネイティブフォーマットを返します
742
+ - 両エンドポイントとも同一のAPIパスワードを使用
743
+
744
+ ## 📋 完全なAPIリファレンス
745
+
746
+ ### Webコンソール API
747
+
748
+ **認証エンドポイント**
749
+ - `POST /auth/login` - ユーザーログイン
750
+ - `POST /auth/start` - OAuth認証の開始(GCLIおよびAntigravityモード対応)
751
+ - `POST /auth/callback` - OAuthコールバックの処理
752
+ - `POST /auth/callback-url` - コールバックURLから直接認証を完了
753
+ - `GET /auth/status/{project_id}` - 認証ステータスの確認
754
+
755
+ **クレデンシャル管理エンドポイント**(`mode=geminicli` または `mode=antigravity` パラメータ対応)
756
+ - `POST /creds/upload` - クレデンシャルファイルの一括アップロード(JSONおよびZIP対応)
757
+ - `GET /creds/status` - クレデンシャルステータス一覧の取得(ページネーションとフィルタリング対応)
758
+ - `GET /creds/detail/{filename}` - 単一クレデンシャルの詳細取得
759
+ - `POST /creds/action` - 単一クレデンシャル操作(有効化/無効化/削除)
760
+ - `POST /creds/batch-action` - クレデンシャルの一括操作
761
+ - `GET /creds/download/{filename}` - 単一クレデンシャルファイルのダウンロード
762
+ - `GET /creds/download-all` - 全クレデンシャルの一括ダウンロード
763
+ - `POST /creds/fetch-email/{filename}` - ユーザーメールの取得
764
+ - `POST /creds/refresh-all-emails` - ユーザーメールの一括更新
765
+ - `POST /creds/deduplicate-by-email` - メールによるクレデンシャルの重複排除
766
+ - `POST /creds/verify-project/{filename}` - クレデンシャルのProject ID検証
767
+ - `GET /creds/quota/{filename}` - クレデンシャルのクォータ情報取得(Antigravityのみ)
768
+
769
+ **設定管理エンドポイント**
770
+ - `GET /config/get` - 現在の設定の取得
771
+ - `POST /config/save` - 設定の保存
772
+
773
+ **ログ管理エンドポイント**
774
+ - `POST /logs/clear` - ログのクリア
775
+ - `GET /logs/download` - ログファイルのダウンロード
776
+ - `WebSocket /logs/stream` - リアルタイムログストリーム
777
+
778
+ **バージョン情報エンドポイント**
779
+ - `GET /version/info` - バージョン情報の取得(オプション `check_update=true` パラメータで更新確認)
780
+
781
+ ### チャットAPI機能
782
+
783
+ **マルチモーダル対応**
784
+ ```json
785
+ {
786
+ "model": "gemini-2.5-pro",
787
+ "messages": [
788
+ {
789
+ "role": "user",
790
+ "content": [
791
+ {"type": "text", "text": "この画像を説明してください"},
792
+ {
793
+ "type": "image_url",
794
+ "image_url": {
795
+ "url": "data:image/jpeg;base64,/9j/4AAQSkZJRgABA..."
796
+ }
797
+ }
798
+ ]
799
+ }
800
+ ]
801
+ }
802
+ ```
803
+
804
+ **Thinkingモード対応**
805
+ ```json
806
+ {
807
+ "model": "gemini-2.5-pro-high",
808
+ "messages": [
809
+ {"role": "user", "content": "複雑な数学の問題"}
810
+ ]
811
+ }
812
+ ```
813
+
814
+ レスポンスには分離された思考内容が含まれます:
815
+ ```json
816
+ {
817
+ "choices": [{
818
+ "message": {
819
+ "role": "assistant",
820
+ "content": "最終回答",
821
+ "reasoning_content": "詳細な思考プロセス..."
822
+ }
823
+ }]
824
+ }
825
+ ```
826
+
827
+ **ストリーミング途切れ防止の使用方法**
828
+ ```json
829
+ {
830
+ "model": "流式抗截断/gemini-2.5-pro",
831
+ "messages": [
832
+ {"role": "user", "content": "長い記事を書いてください"}
833
+ ],
834
+ "stream": true
835
+ }
836
+ ```
837
+
838
+ **互換モード**
839
+ ```bash
840
+ # 互換モードを有効化
841
+ export COMPATIBILITY_MODE=true
842
+ ```
843
+ このモードでは、すべての `system` メッセージが `user` メッセージに変換され、特定のクライアントとの互換性が向上します。
844
+
845
+ ---
846
+
847
+ ## 💬 コミュニティ
848
+
849
+ QQグループへの参加をお待ちしています!
850
+
851
+ **QQグループ: 1083250744**
852
+
853
+ <img src="qq群.jpg" width="200" alt="QQグループQRコード">
854
+
855
+ ---
856
+
857
+ ## ライセンスと免責事項
858
+
859
+ 本プロジェクトは学��および研究目的のみに使用できます。本プロジェクトの使用は、以下に同意したことを意味します:
860
+ - 本プロジェクトをいかなる商用目的にも使用しないこと
861
+ - 本プロジェクトの使用に伴うすべてのリスクと責任を負うこと
862
+ - 関連するサービス利用規約および法的規制を遵守すること
863
+
864
+ プロジェクトの作者は、本プロジェクトの使用から生じるいかなる直接的または間接的な損害についても責任を負いません。
865
+
866
+ ## ⚠️ ライセンスについて
867
+
868
+ **本プロジェクトはCooperative Non-Commercial License (CNC-1.0) の下でライセンスされています**
869
+
870
+ これは厳格な非商用オープンソースライセンスです。詳細は [LICENSE](../LICENSE) ファイルをご参照ください。
871
+
872
+ ### ✅ 許可される用途:
873
+ - 個人の学習、研究、教育目的
874
+ - 非営利団体での利用
875
+ - オープンソースプロジェクトへの統合(同一ライセンスの遵守が必要)
876
+ - 学術研究および論文発表
877
+
878
+ ### ❌ 禁止される用途:
879
+ - あらゆる形態の商用利用
880
+ - 年間売上が100万ドルを超える企業での利用
881
+ - ベンチャーキャピタルの出資を受けた企業または上場企業
882
+ - 有料サービスまたは製品の提供
883
+ - 商業的な競合利用
front/common.js ADDED
The diff for this file is too large to render. See raw diff
 
front/control_panel.html ADDED
The diff for this file is too large to render. See raw diff
 
front/control_panel_mobile.html ADDED
The diff for this file is too large to render. See raw diff
 
install.ps1 ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 检测是否为管理员
2
+ $IsElevated = ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).
3
+ IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
4
+
5
+ # Skip Scoop install if already present to avoid stopping the script
6
+ if (Get-Command scoop -ErrorAction SilentlyContinue) {
7
+ Write-Host "Scoop is already installed. Skipping installation."
8
+ } else {
9
+ Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser -Force
10
+ if ($IsElevated) {
11
+ # 管理员:使用官方一行命令并传入 -RunAsAdmin
12
+ Invoke-Expression "& {$(Invoke-RestMethod get.scoop.sh)} -RunAsAdmin"
13
+ } else {
14
+ # 普通用户安装
15
+ Invoke-WebRequest -useb get.scoop.sh | Invoke-Expression
16
+ }
17
+ }
18
+
19
+ scoop install git uv
20
+ if (Test-Path -LiteralPath "./web.py") {
21
+ # Already in target directory; skip clone and cd
22
+ }
23
+ elseif (Test-Path -LiteralPath "./gcli2api/web.py") {
24
+ Set-Location ./gcli2api
25
+ }
26
+ else {
27
+ git clone https://github.com/su-kaka/gcli2api.git
28
+ Set-Location ./gcli2api
29
+ }
30
+ # Create relocatable virtual environment to ensure portability
31
+ $env:UV_VENV_CLEAR = "1"
32
+ uv venv --relocatable
33
+ uv sync
34
+ .venv/Scripts/activate.ps1
35
+ python web.py
install.sh ADDED
@@ -0,0 +1,302 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+ set -e # Exit on error
3
+ set -u # Exit on undefined variable
4
+ set -o pipefail # Exit on pipe failure
5
+
6
+ # Color codes for output
7
+ RED='\033[0;31m'
8
+ GREEN='\033[0;32m'
9
+ YELLOW='\033[1;33m'
10
+ BLUE='\033[0;34m'
11
+ NC='\033[0m' # No Color
12
+
13
+ # Logging functions
14
+ log_info() {
15
+ echo -e "${GREEN}[INFO]${NC} $1"
16
+ }
17
+
18
+ log_error() {
19
+ echo -e "${RED}[ERROR]${NC} $1" >&2
20
+ }
21
+
22
+ log_warn() {
23
+ echo -e "${YELLOW}[WARN]${NC} $1"
24
+ }
25
+
26
+ log_debug() {
27
+ echo -e "${BLUE}[DEBUG]${NC} $1"
28
+ }
29
+
30
+ # Cleanup function for error handling
31
+ cleanup() {
32
+ local exit_code=$?
33
+ if [ $exit_code -ne 0 ]; then
34
+ log_error "Installation failed with exit code $exit_code"
35
+ fi
36
+ exit $exit_code
37
+ }
38
+
39
+ trap cleanup EXIT
40
+
41
+ # Detect OS and distribution
42
+ detect_os() {
43
+ log_info "Detecting operating system..."
44
+
45
+ if [[ "$OSTYPE" == "linux-gnu"* ]]; then
46
+ if [ -f /etc/os-release ]; then
47
+ . /etc/os-release
48
+ OS_NAME=$ID
49
+ OS_VERSION=$VERSION_ID
50
+ log_info "Detected: $NAME $VERSION_ID"
51
+ elif [ -f /etc/lsb-release ]; then
52
+ . /etc/lsb-release
53
+ OS_NAME=$DISTRIB_ID
54
+ OS_VERSION=$DISTRIB_RELEASE
55
+ log_info "Detected: $DISTRIB_ID $DISTRIB_RELEASE"
56
+ else
57
+ OS_NAME="linux"
58
+ OS_VERSION="unknown"
59
+ log_warn "Could not determine specific Linux distribution"
60
+ fi
61
+ elif [[ "$OSTYPE" == "darwin"* ]]; then
62
+ OS_NAME="macos"
63
+ OS_VERSION=$(sw_vers -productVersion)
64
+ log_info "Detected: macOS $OS_VERSION"
65
+ elif [[ "$OSTYPE" == "freebsd"* ]]; then
66
+ OS_NAME="freebsd"
67
+ OS_VERSION=$(freebsd-version)
68
+ log_info "Detected: FreeBSD $OS_VERSION"
69
+ else
70
+ log_error "Unsupported operating system: $OSTYPE"
71
+ exit 1
72
+ fi
73
+ }
74
+
75
+ # Check for root privileges (only for Linux package managers that need it)
76
+ check_root_if_needed() {
77
+ if [[ "$OS_NAME" == "ubuntu" ]] || [[ "$OS_NAME" == "debian" ]] || [[ "$OS_NAME" == "linuxmint" ]] || [[ "$OS_NAME" == "kali" ]]; then
78
+ if [ "$EUID" -ne 0 ]; then
79
+ log_error "This script requires root privileges for apt. Please run with sudo."
80
+ exit 1
81
+ fi
82
+ elif [[ "$OS_NAME" == "fedora" ]] || [[ "$OS_NAME" == "rhel" ]] || [[ "$OS_NAME" == "centos" ]] || [[ "$OS_NAME" == "rocky" ]] || [[ "$OS_NAME" == "almalinux" ]]; then
83
+ if [ "$EUID" -ne 0 ]; then
84
+ log_error "This script requires root privileges for dnf/yum. Please run with sudo."
85
+ exit 1
86
+ fi
87
+ elif [[ "$OS_NAME" == "arch" ]] || [[ "$OS_NAME" == "manjaro" ]]; then
88
+ if [ "$EUID" -ne 0 ]; then
89
+ log_error "This script requires root privileges for pacman. Please run with sudo."
90
+ exit 1
91
+ fi
92
+ fi
93
+ }
94
+
95
+ # Update package manager
96
+ update_packages() {
97
+ log_info "Updating package manager..."
98
+
99
+ case "$OS_NAME" in
100
+ ubuntu|debian|linuxmint|kali|pop)
101
+ if ! apt update; then
102
+ log_error "Failed to update apt package lists"
103
+ exit 1
104
+ fi
105
+ ;;
106
+ fedora|rhel|centos|rocky|almalinux)
107
+ if command -v dnf &> /dev/null; then
108
+ if ! dnf check-update; then
109
+ # dnf check-update returns 100 if updates are available, which is not an error
110
+ if [ $? -ne 100 ]; then
111
+ log_warn "dnf check-update returned non-standard exit code"
112
+ fi
113
+ fi
114
+ else
115
+ if ! yum check-update; then
116
+ if [ $? -ne 100 ]; then
117
+ log_warn "yum check-update returned non-standard exit code"
118
+ fi
119
+ fi
120
+ fi
121
+ ;;
122
+ arch|manjaro)
123
+ if ! pacman -Syu; then
124
+ log_error "Failed to update pacman database"
125
+ exit 1
126
+ fi
127
+ ;;
128
+ macos)
129
+ if command -v brew &> /dev/null; then
130
+ log_info "Updating Homebrew..."
131
+ brew update
132
+ else
133
+ log_warn "Homebrew not installed. Skipping package manager update."
134
+ fi
135
+ ;;
136
+ *)
137
+ log_warn "Unknown package manager for $OS_NAME. Skipping update."
138
+ ;;
139
+ esac
140
+ }
141
+
142
+ # Install git based on OS
143
+ install_git() {
144
+ if ! command -v git &> /dev/null; then
145
+ log_info "Installing git..."
146
+
147
+ case "$OS_NAME" in
148
+ ubuntu|debian|linuxmint|kali|pop)
149
+ if ! apt install git -y; then
150
+ log_error "Failed to install git"
151
+ exit 1
152
+ fi
153
+ ;;
154
+ fedora|rhel|centos|rocky|almalinux)
155
+ if command -v dnf &> /dev/null; then
156
+ if ! dnf install git -y; then
157
+ log_error "Failed to install git"
158
+ exit 1
159
+ fi
160
+ else
161
+ if ! yum install git -y; then
162
+ log_error "Failed to install git"
163
+ exit 1
164
+ fi
165
+ fi
166
+ ;;
167
+ arch|manjaro)
168
+ if ! pacman -S git --noconfirm; then
169
+ log_error "Failed to install git"
170
+ exit 1
171
+ fi
172
+ ;;
173
+ macos)
174
+ if command -v brew &> /dev/null; then
175
+ if ! brew install git; then
176
+ log_error "Failed to install git"
177
+ exit 1
178
+ fi
179
+ else
180
+ log_error "Homebrew is required for macOS. Install from https://brew.sh/"
181
+ exit 1
182
+ fi
183
+ ;;
184
+ *)
185
+ log_error "Don't know how to install git on $OS_NAME"
186
+ exit 1
187
+ ;;
188
+ esac
189
+ else
190
+ log_info "Git is already installed ($(git --version))"
191
+ fi
192
+ }
193
+
194
+ # Detect OS first
195
+ detect_os
196
+
197
+ # Check root if needed
198
+ check_root_if_needed
199
+
200
+ log_info "Starting installation process..."
201
+
202
+ # Update package lists
203
+ update_packages
204
+
205
+ # Install git
206
+ install_git
207
+
208
+ # Install uv if not present
209
+ if ! command -v uv &> /dev/null; then
210
+ log_info "Installing uv package manager..."
211
+ if ! curl -Ls https://astral.sh/uv/install.sh | sh; then
212
+ log_error "Failed to install uv"
213
+ exit 1
214
+ fi
215
+
216
+ # Source environment
217
+ if [ -f "$HOME/.local/bin/env" ]; then
218
+ source "$HOME/.local/bin/env"
219
+ elif [ -f "$HOME/.cargo/env" ]; then
220
+ source "$HOME/.cargo/env"
221
+ fi
222
+
223
+ # Verify uv installation
224
+ if ! command -v uv &> /dev/null; then
225
+ log_error "uv installation failed - command not found after install"
226
+ exit 1
227
+ fi
228
+ else
229
+ log_info "uv is already installed"
230
+ fi
231
+
232
+ # Determine working directory
233
+ log_info "Checking project directory..."
234
+ if [ -f "./web.py" ]; then
235
+ log_info "Already in target directory"
236
+ elif [ -f "./gcli2api/web.py" ]; then
237
+ log_info "Changing to gcli2api directory"
238
+ cd ./gcli2api || exit 1
239
+ else
240
+ log_info "Cloning repository..."
241
+ if [ -d "./gcli2api" ]; then
242
+ log_warn "gcli2api directory exists but web.py not found. Removing and re-cloning..."
243
+ rm -rf ./gcli2api
244
+ fi
245
+
246
+ if ! git clone https://github.com/su-kaka/gcli2api.git; then
247
+ log_error "Failed to clone repository"
248
+ exit 1
249
+ fi
250
+
251
+ cd ./gcli2api || exit 1
252
+ fi
253
+
254
+ # Update repository if it's a git repo
255
+ if [ -d ".git" ]; then
256
+ log_info "Updating repository..."
257
+ if ! git pull; then
258
+ log_warn "Git pull failed, continuing anyway..."
259
+ fi
260
+ else
261
+ log_warn "Not a git repository, skipping update"
262
+ fi
263
+
264
+ # Create relocatable virtual environment to ensure portability
265
+ log_info "Creating relocatable virtual environment..."
266
+ export UV_VENV_CLEAR=1
267
+ if ! uv venv --relocatable; then
268
+ log_error "Failed to create virtual environment"
269
+ exit 1
270
+ fi
271
+
272
+ # Sync dependencies
273
+ log_info "Syncing dependencies with uv..."
274
+ if ! uv sync; then
275
+ log_error "Failed to sync dependencies"
276
+ exit 1
277
+ fi
278
+
279
+ # Activate virtual environment
280
+ log_info "Activating virtual environment..."
281
+ if [ -f ".venv/bin/activate" ]; then
282
+ source .venv/bin/activate
283
+ else
284
+ log_error "Virtual environment not found at .venv/bin/activate"
285
+ exit 1
286
+ fi
287
+
288
+ # Verify Python is available
289
+ if ! command -v python3 &> /dev/null; then
290
+ log_error "python3 not found in virtual environment"
291
+ exit 1
292
+ fi
293
+
294
+ # Check if web.py exists
295
+ if [ ! -f "web.py" ]; then
296
+ log_error "web.py not found in current directory"
297
+ exit 1
298
+ fi
299
+
300
+ # Start the application
301
+ log_info "Starting application..."
302
+ python3 web.py
log.py ADDED
@@ -0,0 +1,327 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 日志模块 - 使用环境变量配置
3
+ """
4
+
5
+ import os
6
+ import sys
7
+ import threading
8
+ from datetime import datetime
9
+ from collections import deque
10
+ import atexit
11
+
12
+ # 日志级别定义
13
+ LOG_LEVELS = {"debug": 0, "info": 1, "warning": 2, "error": 3, "critical": 4}
14
+
15
+ # 文件写入状态标志(仅由 writer 线程修改,无需锁保护)
16
+ _file_writing_disabled = False
17
+ _disable_reason = None
18
+
19
+ # 全局文件句柄(仅由 writer 线程访问,无需文件锁)
20
+ _log_file_handle = None
21
+
22
+ # -----------------------------------------------------------------
23
+ # 高性能无锁队列:用 deque + Condition 替代 Queue
24
+ # deque.append / deque.popleft 在 CPython 中受 GIL 保护,是原子操作,
25
+ # 不需要额外的 Lock 做入队保护,只用 Condition 做"有数据"通知。
26
+ # -----------------------------------------------------------------
27
+ _log_deque: deque = deque()
28
+ _deque_condition = threading.Condition(threading.Lock())
29
+ _writer_thread = None
30
+ _writer_running = False
31
+
32
+ # -----------------------------------------------------------------
33
+ # 缓存日志级别,避免每次都读 os.getenv(高并发热路径)
34
+ # -----------------------------------------------------------------
35
+ _cached_log_level: int = LOG_LEVELS["info"]
36
+ _cached_log_file: str = "log.txt"
37
+ # ENABLE_LOG=0/false/no/off 时彻底关闭日志
38
+ _log_enabled: bool = True
39
+
40
+
41
+ def _refresh_config():
42
+ """从环境变量刷新缓存配置(模块加载时及需要时调用)"""
43
+ global _cached_log_level, _cached_log_file, _log_enabled
44
+ level = os.getenv("LOG_LEVEL", "info").lower()
45
+ _cached_log_level = LOG_LEVELS.get(level, LOG_LEVELS["info"])
46
+ _cached_log_file = os.getenv("LOG_FILE", "log.txt")
47
+ _log_enabled = os.getenv("ENABLE_LOG", "1").strip().lower() not in ("0", "false", "no", "off")
48
+
49
+
50
+ def _get_current_log_level() -> int:
51
+ return _cached_log_level
52
+
53
+
54
+ def _get_log_file_path() -> str:
55
+ return _cached_log_file
56
+
57
+
58
+ # -----------------------------------------------------------------
59
+ # 文件句柄管理(仅在 writer 线程内调用,不需要 _file_lock)
60
+ # -----------------------------------------------------------------
61
+
62
+ def _close_log_file():
63
+ global _log_file_handle
64
+ if _log_file_handle is not None:
65
+ try:
66
+ _log_file_handle.flush()
67
+ _log_file_handle.close()
68
+ except Exception:
69
+ pass
70
+ finally:
71
+ _log_file_handle = None
72
+
73
+
74
+ def _open_log_file(mode: str = "a") -> bool:
75
+ global _log_file_handle, _file_writing_disabled, _disable_reason
76
+ _close_log_file()
77
+ try:
78
+ # 使用较大缓冲区(64 KB),由 writer 线程定期 flush,减少系统调用
79
+ _log_file_handle = open(_cached_log_file, mode, encoding="utf-8", buffering=65536)
80
+ return True
81
+ except (PermissionError, OSError, IOError) as e:
82
+ _file_writing_disabled = True
83
+ _disable_reason = str(e)
84
+ print(f"Warning: Cannot open log file, disabling file writing: {e}", file=sys.stderr)
85
+ print("Log messages will continue to display in console only.", file=sys.stderr)
86
+ return False
87
+ except Exception as e:
88
+ print(f"Warning: Failed to open log file: {e}", file=sys.stderr)
89
+ return False
90
+
91
+
92
+ def _clear_log_file():
93
+ """清空日志文件(启动时调用,此时 writer 线程尚未启动,直接操作安全)"""
94
+ global _file_writing_disabled, _disable_reason
95
+ try:
96
+ with open(_cached_log_file, "w", encoding="utf-8") as f:
97
+ pass # 覆盖清空
98
+ _open_log_file("a")
99
+ except (PermissionError, OSError, IOError) as e:
100
+ _file_writing_disabled = True
101
+ _disable_reason = str(e)
102
+ print(
103
+ f"Warning: File system appears to be read-only or permission denied. "
104
+ f"Disabling log file writing: {e}",
105
+ file=sys.stderr,
106
+ )
107
+ print("Log messages will continue to display in console only.", file=sys.stderr)
108
+ except Exception as e:
109
+ print(f"Warning: Failed to clear log file: {e}", file=sys.stderr)
110
+
111
+
112
+ # -----------------------------------------------------------------
113
+ # Writer 线程:批量从 deque 取出并写入,减少系统调用次数
114
+ # -----------------------------------------------------------------
115
+ _BATCH_SIZE = 1000 # 单次最多批量写入条数
116
+ _FLUSH_INTERVAL = 2 # 秒:无新消息时强制 flush 周期
117
+
118
+
119
+ def _log_writer_worker():
120
+ global _writer_running
121
+
122
+ last_flush_time = 0.0
123
+
124
+ while True:
125
+ # 等待数据或超时
126
+ with _deque_condition:
127
+ if not _log_deque and _writer_running:
128
+ _deque_condition.wait(timeout=_FLUSH_INTERVAL)
129
+
130
+ # 批量取出
131
+ batch = []
132
+ for _ in range(_BATCH_SIZE):
133
+ if _log_deque:
134
+ batch.append(_log_deque.popleft())
135
+ else:
136
+ break
137
+
138
+ if batch and not _file_writing_disabled:
139
+ # 一次 write 调用��定整批,最大化减少系统调用
140
+ chunk = "\n".join(batch) + "\n"
141
+ try:
142
+ if _log_file_handle is None:
143
+ _open_log_file("a")
144
+ if _log_file_handle is not None:
145
+ _log_file_handle.write(chunk)
146
+ except Exception as e:
147
+ print(f"Warning: Failed to write log batch: {e}", file=sys.stderr)
148
+ _close_log_file()
149
+ try:
150
+ _open_log_file("a")
151
+ except Exception:
152
+ pass
153
+
154
+ # 定时 flush
155
+ now = _now_ts()
156
+ if now - last_flush_time >= _FLUSH_INTERVAL:
157
+ if _log_file_handle is not None:
158
+ try:
159
+ _log_file_handle.flush()
160
+ except Exception:
161
+ pass
162
+ last_flush_time = now
163
+
164
+ # 退出条件:已停止 + deque 已清空
165
+ if not _writer_running and not _log_deque:
166
+ break
167
+
168
+ # 最终 flush & close
169
+ if _log_file_handle is not None:
170
+ try:
171
+ _log_file_handle.flush()
172
+ except Exception:
173
+ pass
174
+ _close_log_file()
175
+
176
+
177
+ def _now_ts() -> float:
178
+ import time
179
+ return time.monotonic()
180
+
181
+
182
+ def _start_writer_thread():
183
+ global _writer_thread, _writer_running
184
+
185
+ if _writer_thread is None or not _writer_thread.is_alive():
186
+ _writer_running = True
187
+ _writer_thread = threading.Thread(target=_log_writer_worker, daemon=True, name="LogWriter")
188
+ _writer_thread.start()
189
+
190
+
191
+ def _stop_writer_thread():
192
+ global _writer_running
193
+
194
+ _writer_running = False
195
+ # 唤醒 writer 线程让它能感知退出信号
196
+ with _deque_condition:
197
+ _deque_condition.notify_all()
198
+
199
+ if _writer_thread and _writer_thread.is_alive():
200
+ _writer_thread.join(timeout=3.0)
201
+
202
+
203
+ # -----------------------------------------------------------------
204
+ # 入队(热路径,极轻量)
205
+ # -----------------------------------------------------------------
206
+ _MAX_QUEUE_SIZE = 5000 # 防止极端情况内存无限增长
207
+
208
+
209
+ def _write_to_file(message: str):
210
+ if _file_writing_disabled:
211
+ return
212
+ # deque.append 在 CPython 受 GIL 保护,无需额外锁
213
+ if len(_log_deque) >= _MAX_QUEUE_SIZE:
214
+ return # 过载保护:丢弃而非阻塞
215
+ _log_deque.append(message)
216
+ # 非阻塞通知 writer(acquire 失败直接跳过,不影响主线程)
217
+ if _deque_condition.acquire(blocking=False):
218
+ try:
219
+ _deque_condition.notify()
220
+ finally:
221
+ _deque_condition.release()
222
+
223
+
224
+ # -----------------------------------------------------------------
225
+ # 核心日志函数(热路径)
226
+ # -----------------------------------------------------------------
227
+
228
+ def _log(level: str, message: str):
229
+ # 最快短路:日志整体已禁用时直接返回,零开销
230
+ if not _log_enabled:
231
+ return
232
+
233
+ level = level.lower()
234
+ level_val = LOG_LEVELS.get(level)
235
+ if level_val is None:
236
+ print(f"Warning: Unknown log level '{level}'", file=sys.stderr)
237
+ return
238
+
239
+ # 热路径:直接与缓存值比较,无函数调用开销
240
+ if level_val < _cached_log_level:
241
+ return
242
+
243
+ timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
244
+ entry = f"[{timestamp}] [{level.upper()}] {message}"
245
+
246
+ if level in ("error", "critical"):
247
+ print(entry, file=sys.stderr)
248
+ else:
249
+ print(entry)
250
+
251
+ _write_to_file(entry)
252
+
253
+
254
+ def set_log_level(level: str):
255
+ """动态设置日志级别(同时更新缓存)"""
256
+ global _cached_log_level
257
+ level = level.lower()
258
+ if level not in LOG_LEVELS:
259
+ print(f"Warning: Unknown log level '{level}'. Valid levels: {', '.join(LOG_LEVELS.keys())}")
260
+ return False
261
+ _cached_log_level = LOG_LEVELS[level]
262
+ return True
263
+
264
+
265
+ class Logger:
266
+ """支持 log('info', 'msg') 和 log.info('msg') 两种调用方式"""
267
+
268
+ def __call__(self, level: str, message: str):
269
+ _log(level, message)
270
+
271
+ def debug(self, message: str):
272
+ _log("debug", message)
273
+
274
+ def info(self, message: str):
275
+ _log("info", message)
276
+
277
+ def warning(self, message: str):
278
+ _log("warning", message)
279
+
280
+ def error(self, message: str):
281
+ _log("error", message)
282
+
283
+ def critical(self, message: str):
284
+ _log("critical", message)
285
+
286
+ def get_current_level(self) -> str:
287
+ current_level = _get_current_log_level()
288
+ for name, value in LOG_LEVELS.items():
289
+ if value == current_level:
290
+ return name
291
+ return "info"
292
+
293
+ def get_log_file(self) -> str:
294
+ return _get_log_file_path()
295
+
296
+ def close(self):
297
+ """手动关闭(优雅退出用)"""
298
+ _stop_writer_thread()
299
+
300
+ def get_queue_size(self) -> int:
301
+ return len(_log_deque)
302
+
303
+
304
+ # 导出全局日志实例
305
+ log = Logger()
306
+
307
+ # 导出的公共接口
308
+ __all__ = ["log", "set_log_level", "LOG_LEVELS"]
309
+
310
+ # 模块加载时:读取配置���存 → 清空日志文件 → 启动 writer 线程
311
+ _refresh_config()
312
+ if _log_enabled:
313
+ _clear_log_file()
314
+ _start_writer_thread()
315
+
316
+ # 注册退出清理
317
+ atexit.register(_stop_writer_thread)
318
+
319
+ # 使用说明:
320
+ # 1. 设置日志级别: export LOG_LEVEL=debug (或在 .env 中设置)
321
+ # 2. 设置日志文件: export LOG_FILE=log.txt (或在 .env 中设置)
322
+ # 3. 日志级别已缓存,热路径零 os.getenv 调用
323
+ # 4. 写入线程批量处理(最多 200 条/次),64 KB 缓冲区,每 0.5 s flush 一次
324
+ # 5. 队列上限 5000 条,超出时丢弃新日志(过载保护,不阻塞主线程)
325
+ # 6. 动态调整级别:set_log_level('debug') 立即生效
326
+ # 7. 彻底关闭日志(最高性能):export ENABLE_LOG=0 (或 false/no/off)
327
+ # 关闭后不会启动 writer 线程、不写文件、不打印控制台,_log 直接 return
pyproject.toml ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [project]
2
+ name = "gcli2api"
3
+ version = "0.1.0"
4
+ description = "Convert GeminiCLI to OpenAI and Gemini API interfaces"
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ license = {text = "CNC-1.0"}
8
+ authors = [
9
+ {name = "su-kaka"}
10
+ ]
11
+ keywords = ["gemini", "openai", "api", "converter", "cli"]
12
+ classifiers = [
13
+ "Development Status :: 4 - Beta",
14
+ "Intended Audience :: Developers",
15
+ "License :: Other/Proprietary License",
16
+ "Programming Language :: Python :: 3",
17
+ "Programming Language :: Python :: 3.13",
18
+ ]
19
+ dependencies = [
20
+ "aiofiles>=24.1.0",
21
+ "fastapi>=0.116.1",
22
+ "httpx[socks]>=0.28.1",
23
+ "hypercorn>=0.17.3",
24
+ "motor>=3.7.1",
25
+ "oauthlib>=3.3.1",
26
+ "pydantic>=2.11.7",
27
+ "pyjwt>=2.10.1",
28
+ "python-dotenv>=1.1.1",
29
+ "python-multipart>=0.0.20",
30
+ "pypinyin>=0.51.0",
31
+ "aiosqlite>=0.20.0",
32
+ "redis>=7.2.0",
33
+ "asyncpg>=0.31.0",
34
+ ]
35
+
36
+ [project.optional-dependencies]
37
+ dev = [
38
+ "pytest>=8.0.0",
39
+ "pytest-asyncio>=0.23.0",
40
+ "pytest-cov>=4.1.0",
41
+ "black>=24.0.0",
42
+ "flake8>=7.0.0",
43
+ "mypy>=1.8.0",
44
+ "pre-commit>=3.6.0",
45
+ ]
46
+
47
+ [tool.pytest.ini_options]
48
+ minversion = "8.0"
49
+ testpaths = ["."]
50
+ python_files = ["test_*.py"]
51
+ python_classes = ["Test*"]
52
+ python_functions = ["test_*"]
53
+ asyncio_mode = "auto"
54
+ addopts = [
55
+ "-v",
56
+ "--strict-markers",
57
+ ]
58
+
59
+ [tool.black]
60
+ line-length = 100
61
+ target-version = ["py313"]
62
+ include = '\.pyi?$'
63
+ extend-exclude = '''
64
+ /(
65
+ # directories
66
+ \.eggs
67
+ | \.git
68
+ | \.hg
69
+ | \.mypy_cache
70
+ | \.tox
71
+ | \.venv
72
+ | build
73
+ | dist
74
+ )/
75
+ '''
76
+
77
+ [tool.mypy]
78
+ python_version = "3.13"
79
+ warn_return_any = true
80
+ warn_unused_configs = true
81
+ disallow_untyped_defs = false
82
+ ignore_missing_imports = true
83
+ exclude = [
84
+ "build",
85
+ "dist",
86
+ ]
87
+
88
+ [tool.coverage.run]
89
+ source = ["src"]
90
+ omit = [
91
+ "*/tests/*",
92
+ "*/test_*.py",
93
+ ]
94
+
95
+ [tool.coverage.report]
96
+ exclude_lines = [
97
+ "pragma: no cover",
98
+ "def __repr__",
99
+ "raise AssertionError",
100
+ "raise NotImplementedError",
101
+ "if __name__ == .__main__.:",
102
+ "if TYPE_CHECKING:",
103
+ ]
render.yaml ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ services:
2
+ - type: web
3
+ name: gcli2api
4
+ runtime: docker
5
+ dockerfilePath: ./Dockerfile
6
+ dockerContext: .
7
+ plan: free
8
+ region: singapore
9
+ healthCheckPath: /
10
+
11
+ envVars:
12
+ # ========== 必填:访问密码 ==========
13
+ - key: PASSWORD
14
+ sync: false # 部署时手动填写,不同步到代码库
15
+
16
+ # ========== 服务器配置 ==========
17
+ - key: HOST
18
+ value: 0.0.0.0
19
+ - key: PORT
20
+ value: "10000" # Render 要求 Web 服务监听 10000 端口
requirements-termux.txt ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ fastapi
2
+ httpx[socks]
3
+ pydantic==1.10.22
4
+ python-dotenv
5
+ hypercorn
6
+ aiofiles
7
+ python-multipart
8
+ PyJWT
9
+ oauthlib
10
+ motor
11
+ pypinyin
12
+ aiosqlite
13
+ redis
14
+ asyncpg
requirements.txt ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ fastapi>=0.116.1
2
+ httpx[socks]>=0.28.1
3
+ pydantic>=2.11.7
4
+ python-dotenv>=1.1.1
5
+ hypercorn>=0.17.3
6
+ aiofiles>=24.1.0
7
+ python-multipart>=0.0.20
8
+ PyJWT>=2.10.1
9
+ oauthlib>=3.3.1
10
+ motor>=3.7.1
11
+ aiosqlite>=0.20.0
12
+ pypinyin>=0.51.0
13
+ redis>=4.2.0
14
+ asyncpg
src/api/Response_example.txt ADDED
@@ -0,0 +1,210 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ================================================================================
2
+ GeminiCli API 测试
3
+ ================================================================================
4
+
5
+ ================================================================================
6
+ 【测试1】流式请求 (stream_request with native=False)
7
+ ================================================================================
8
+ 请求体: {
9
+ "model": "gemini-2.5-flash",
10
+ "request": {
11
+ "contents": [
12
+ {
13
+ "role": "user",
14
+ "parts": [
15
+ {
16
+ "text": "Hello, tell me a joke in one sentence."
17
+ }
18
+ ]
19
+ }
20
+ ]
21
+ }
22
+ }
23
+
24
+ 流式响应数据 (每个chunk):
25
+ --------------------------------------------------------------------------------
26
+ [2026-01-10 09:55:29] [INFO] SQLite storage initialized at ./creds\credentials.db
27
+ [2026-01-10 09:55:29] [INFO] Using SQLite storage backend
28
+ [2026-01-10 09:55:31] [INFO] Token刷新成 功并已保存: my-project-9-481103-1765596755.json (mode=geminicli)
29
+ [2026-01-10 09:55:34] [INFO] [DB] 准备commit,总更新行数=1
30
+ [2026-01-10 09:55:34] [INFO] [DB] commit 完成
31
+ [2026-01-10 09:55:34] [INFO] [DB] update_credential_state 结束: success=True, updated_count=1
32
+
33
+ Chunk #1:
34
+ 类型: str
35
+ 长度: 626
36
+ 内容预览: 'data: {"response": {"candidates": [{"content": {"role": "model","parts": [{"text": "Why did the scarecrow win an award? Because he was outstanding in his field."}]},"finishReason": "STOP"}],"usageMeta'
37
+ 解析后的JSON: {
38
+ "response": {
39
+ "candidates": [
40
+ {
41
+ "content": {
42
+ "role": "model",
43
+ "parts": [
44
+ {
45
+ "text": "Why did the scarecrow win an award? Because he was outstanding in his field."
46
+ }
47
+ ]
48
+ },
49
+ "finishReason": "STOP"
50
+ }
51
+ ],
52
+ "usageMetadata": {
53
+ "promptTokenCount": 10,
54
+ "candidatesTokenCount": 17,
55
+ "totalTokenCount": 51,
56
+ "trafficType": "PROVISIONED_THROUGHPUT",
57
+ "promptTokensDetails": [
58
+ {
59
+ "modality": "TEXT",
60
+ "tokenCount": 10
61
+ }
62
+ ],
63
+ "candidatesTokensDetails": [
64
+ {
65
+ "modality": "TEXT",
66
+ "tokenCount": 17
67
+ }
68
+ ],
69
+ "thoughtsTokenCount": 24
70
+ },
71
+ "modelVersion": "gemini-2.5-flash",
72
+ "createTime": "2026-01-10T01:55:29.168589Z",
73
+ "responseId": "kbFhaY2lCr-ZseMPqMiDmAU"
74
+ },
75
+ "traceId": "55650653afd3c738"
76
+ }
77
+
78
+ Chunk #2:
79
+ 类型: str
80
+ 长度: 0
81
+ 内容预览: ''
82
+ E:\projects\gcli2api\src\api\geminicli.py:491: RuntimeWarning: coroutine 'get_auto_ban_error_codes' was never awaited
83
+ async for chunk in stream_request(body=test_body, native=False):
84
+ RuntimeWarning: Enable tracemalloc to get the object allocation traceback
85
+
86
+ 总共收到 2 个chunk
87
+
88
+ ================================================================================
89
+ 【测试2】非流式请求 (non_stream_request)
90
+ ================================================================================
91
+ 请求体: {
92
+ "model": "gemini-2.5-flash",
93
+ "request": {
94
+ "contents": [
95
+ {
96
+ "role": "user",
97
+ "parts": [
98
+ {
99
+ "text": "Hello, tell me a joke in one sentence."
100
+ }
101
+ ]
102
+ }
103
+ ]
104
+ }
105
+ }
106
+
107
+ [2026-01-10 09:55:35] [INFO] Token刷新成 功并已保存: gen-lang-client-0194852792-1767296759.json (mode=geminicli)
108
+ [2026-01-10 09:55:38] [INFO] [DB] 准备commit,总更新行数=1
109
+ [2026-01-10 09:55:38] [INFO] [DB] commit 完成
110
+ [2026-01-10 09:55:38] [INFO] [DB] update_credential_state 结束: success=True, updated_count=1
111
+ E:\projects\gcli2api\src\api\geminicli.py:530: RuntimeWarning: coroutine 'get_auto_ban_error_codes' was never awaited
112
+ response = await non_stream_request(body=test_body)
113
+ RuntimeWarning: Enable tracemalloc to get the object allocation traceback
114
+ 非流式响应数据:
115
+ --------------------------------------------------------------------------------
116
+ 状态码: 200
117
+ Content-Type: application/json; charset=UTF-8
118
+
119
+ 响应头: {'server': 'openresty', 'date': 'Sat, 10 Jan 2026 01:55:34 GMT', 'content-type': 'application/json; charset=UTF-8', 'transfer-encoding': 'chunked', 'connection': 'keep-alive', 'x-cloudaicompanion-trace-id': 'bf3a5eb6636774d2', 'vary': 'Origin, X-Origin, Referer', 'content-encoding': 'gzip', 'x-xss-protection': '0', 'x-frame-options': 'SAMEORIGIN', 'x-content-type-options': 'nosniff', 'server-timing': 'gfet4t7; dur=1377', 'alt-svc': 'h3=":443"; ma=2592000,h3-29=":443"; ma=2592000', 'access-control-allow-origin': '*', 'access-control-allow-methods': 'GET, POST, PUT, DELETE, PATCH, OPTIONS', 'access-control-allow-headers': 'Content-Type, Authorization, X-Requested-With', 'cache-control': 'no-cache', 'content-length': '969'}
120
+
121
+ 响应内容 (原始):
122
+ {
123
+ "response": {
124
+ "candidates": [
125
+ {
126
+ "content": {
127
+ "role": "model",
128
+ "parts": [
129
+ {
130
+ "text": "Why did the scarecrow win an award? Because he was outstanding in his field!"
131
+ }
132
+ ]
133
+ },
134
+ "finishReason": "STOP",
135
+ "avgLogprobs": -0.54438119776108684
136
+ }
137
+ ],
138
+ "usageMetadata": {
139
+ "promptTokenCount": 10,
140
+ "candidatesTokenCount": 17,
141
+ "totalTokenCount": 47,
142
+ "trafficType": "PROVISIONED_THROUGHPUT",
143
+ "promptTokensDetails": [
144
+ {
145
+ "modality": "TEXT",
146
+ "tokenCount": 10
147
+ }
148
+ ],
149
+ "candidatesTokensDetails": [
150
+ {
151
+ "modality": "TEXT",
152
+ "tokenCount": 17
153
+ }
154
+ ],
155
+ "thoughtsTokenCount": 20
156
+ },
157
+ "modelVersion": "gemini-2.5-flash",
158
+ "createTime": "2026-01-10T01:55:33.450396Z",
159
+ "responseId": "lbFhady-G7yi694PmLOP4As"
160
+ },
161
+ "traceId": "bf3a5eb6636774d2"
162
+ }
163
+
164
+
165
+ 响应内容 (格式化JSON):
166
+ {
167
+ "response": {
168
+ "candidates": [
169
+ {
170
+ "content": {
171
+ "role": "model",
172
+ "parts": [
173
+ {
174
+ "text": "Why did the scarecrow win an award? Because he was outstanding in his field!"
175
+ }
176
+ ]
177
+ },
178
+ "finishReason": "STOP",
179
+ "avgLogprobs": -0.5443811977610868
180
+ }
181
+ ],
182
+ "usageMetadata": {
183
+ "promptTokenCount": 10,
184
+ "candidatesTokenCount": 17,
185
+ "totalTokenCount": 47,
186
+ "trafficType": "PROVISIONED_THROUGHPUT",
187
+ "promptTokensDetails": [
188
+ {
189
+ "modality": "TEXT",
190
+ "tokenCount": 10
191
+ }
192
+ ],
193
+ "candidatesTokensDetails": [
194
+ {
195
+ "modality": "TEXT",
196
+ "tokenCount": 17
197
+ }
198
+ ],
199
+ "thoughtsTokenCount": 20
200
+ },
201
+ "modelVersion": "gemini-2.5-flash",
202
+ "createTime": "2026-01-10T01:55:33.450396Z",
203
+ "responseId": "lbFhady-G7yi694PmLOP4As"
204
+ },
205
+ "traceId": "bf3a5eb6636774d2"
206
+ }
207
+
208
+ ================================================================================
209
+ 测试完成
210
+ ================================================================================
src/api/antigravity.py ADDED
@@ -0,0 +1,1009 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Antigravity API Client - Handles communication with Google's Antigravity API
3
+ 处理与 Google Antigravity API 的通信
4
+ """
5
+
6
+ import asyncio
7
+ import hashlib
8
+ import json
9
+ import uuid
10
+ from datetime import datetime, timezone
11
+ from typing import Any, Dict, List, Optional, Callable, Tuple
12
+
13
+ from fastapi import Response
14
+ from config import (
15
+ get_antigravity_api_url,
16
+ get_antigravity_stream2nostream,
17
+ get_antigravity_switch_credential_enabled,
18
+ get_auto_ban_error_codes,
19
+ )
20
+ from log import log
21
+
22
+ from src.credential_manager import credential_manager
23
+ from src.httpx_client import stream_post_async, post_async
24
+ from src.models import Model, model_to_dict
25
+ from src.utils import ANTIGRAVITY_USER_AGENT
26
+
27
+ # 导入共同的基础功能
28
+ from src.api.utils import (
29
+ handle_error_with_retry,
30
+ check_should_auto_ban,
31
+ get_retry_config,
32
+ record_api_call_success,
33
+ record_api_call_error,
34
+ parse_and_log_cooldown,
35
+ collect_streaming_response,
36
+ )
37
+
38
+ # ==================== 全局凭证管理器 ====================
39
+
40
+ # 使用全局单例 credential_manager,自动初始化
41
+
42
+
43
+ # ==================== 辅助函数 ====================
44
+
45
+ def build_antigravity_headers(access_token: str, model_name: str = "") -> Dict[str, str]:
46
+ """
47
+ 构建 Antigravity API 请求头
48
+
49
+ Args:
50
+ access_token: 访问令牌
51
+ model_name: 模型名称,用于判断 request_type
52
+
53
+ Returns:
54
+ 请求头字典
55
+ """
56
+ headers = {
57
+ 'User-Agent': ANTIGRAVITY_USER_AGENT,
58
+ 'Authorization': f'Bearer {access_token}',
59
+ 'Content-Type': 'application/json',
60
+ 'Accept-Encoding': 'gzip',
61
+ 'requestId': f"req-{uuid.uuid4()}"
62
+ }
63
+
64
+ # 根据模型名称判断 request_type
65
+ if model_name:
66
+ # 先判断是否是图片模型
67
+ if "image" in model_name.lower():
68
+ request_type = "image_gen"
69
+ headers['requestType'] = request_type
70
+ else:
71
+ request_type = "agent"
72
+ headers['requestType'] = request_type
73
+
74
+ return headers
75
+
76
+
77
+ def _generate_stable_session_id(request_payload: Dict[str, Any]) -> str:
78
+ contents = request_payload.get("contents")
79
+ if isinstance(contents, list):
80
+ for content in contents:
81
+ if not isinstance(content, dict) or content.get("role") != "user":
82
+ continue
83
+ parts = content.get("parts")
84
+ if not isinstance(parts, list) or not parts:
85
+ continue
86
+ first_part = parts[0]
87
+ if not isinstance(first_part, dict):
88
+ continue
89
+ text = first_part.get("text")
90
+ if isinstance(text, str) and text:
91
+ digest = hashlib.sha256(text.encode("utf-8")).digest()
92
+ value = int.from_bytes(digest[:8], "big") & 0x7FFFFFFFFFFFFFFF
93
+ return f"-{value}"
94
+
95
+ value = uuid.uuid4().int % 9_000_000_000_000_000_000
96
+ return f"-{value}"
97
+
98
+
99
+ def _ensure_antigravity_session_id(payload: Dict[str, Any], model_name: str) -> None:
100
+ if "image" in (model_name or "").lower():
101
+ return
102
+
103
+ request_payload = payload.get("request")
104
+ if not isinstance(request_payload, dict):
105
+ return
106
+
107
+ if request_payload.get("sessionId"):
108
+ return
109
+
110
+ request_payload["sessionId"] = _generate_stable_session_id(request_payload)
111
+
112
+
113
+ def _empty_object_schema() -> Dict[str, Any]:
114
+ return {"type": "object", "properties": {}}
115
+
116
+
117
+ def _prepare_antigravity_tool(tool: Any, is_claude: bool) -> Any:
118
+ if not isinstance(tool, dict):
119
+ return tool
120
+
121
+ normalized_tool = tool.copy()
122
+
123
+ custom_tool = normalized_tool.get("custom")
124
+ if isinstance(custom_tool, dict):
125
+ normalized_custom = custom_tool.copy()
126
+ if "input_schema" not in normalized_custom:
127
+ schema = (
128
+ normalized_custom.pop("parametersJsonSchema", None)
129
+ or normalized_custom.pop("parameters_json_schema", None)
130
+ or normalized_custom.get("parameters")
131
+ )
132
+ normalized_custom["input_schema"] = schema or _empty_object_schema()
133
+ normalized_tool["custom"] = normalized_custom
134
+
135
+ declarations_key = None
136
+ declarations = None
137
+ if isinstance(normalized_tool.get("functionDeclarations"), list):
138
+ declarations_key = "functionDeclarations"
139
+ declarations = normalized_tool.get("functionDeclarations")
140
+ elif isinstance(normalized_tool.get("function_declarations"), list):
141
+ declarations_key = "function_declarations"
142
+ declarations = normalized_tool.get("function_declarations")
143
+
144
+ if isinstance(declarations, list) and declarations_key:
145
+ normalized_declarations = []
146
+ for declaration in declarations:
147
+ if not isinstance(declaration, dict):
148
+ normalized_declarations.append(declaration)
149
+ continue
150
+
151
+ normalized_declaration = declaration.copy()
152
+ schema = None
153
+ if "parametersJsonSchema" in normalized_declaration:
154
+ schema = normalized_declaration.pop("parametersJsonSchema")
155
+ elif "parameters_json_schema" in normalized_declaration:
156
+ schema = normalized_declaration.pop("parameters_json_schema")
157
+ elif "parameters" in normalized_declaration:
158
+ schema = normalized_declaration.get("parameters")
159
+
160
+ if schema not in (None, {}, []):
161
+ normalized_declaration["parameters"] = schema
162
+ elif is_claude or "parameters" not in normalized_declaration:
163
+ normalized_declaration["parameters"] = _empty_object_schema()
164
+
165
+ normalized_declarations.append(normalized_declaration)
166
+
167
+ normalized_tool[declarations_key] = normalized_declarations
168
+
169
+ return normalized_tool
170
+
171
+
172
+ def _prepare_antigravity_payload(payload: Dict[str, Any], model_name: str) -> Dict[str, Any]:
173
+ """Match Antigravity's upstream payload quirks before the HTTP request."""
174
+ payload["userAgent"] = "antigravity"
175
+ if "image" in (model_name or "").lower():
176
+ payload["requestType"] = "image_gen"
177
+ payload.setdefault(
178
+ "requestId",
179
+ f"image_gen/{int(datetime.now(timezone.utc).timestamp() * 1000)}/{uuid.uuid4()}/12",
180
+ )
181
+ else:
182
+ payload["requestType"] = "agent"
183
+ payload.setdefault("requestId", f"agent-{uuid.uuid4()}")
184
+
185
+ request_payload = payload.get("request")
186
+ if not isinstance(request_payload, dict):
187
+ return payload
188
+
189
+ _ensure_antigravity_session_id(payload, model_name)
190
+ request_payload.pop("safetySettings", None)
191
+
192
+ is_claude = "claude" in (model_name or "").lower()
193
+ tools = request_payload.get("tools")
194
+ if isinstance(tools, list):
195
+ request_payload["tools"] = [
196
+ _prepare_antigravity_tool(tool, is_claude)
197
+ for tool in tools
198
+ ]
199
+
200
+ if is_claude:
201
+ tool_config = request_payload.get("toolConfig")
202
+ if not isinstance(tool_config, dict):
203
+ tool_config = {}
204
+ request_payload["toolConfig"] = tool_config
205
+
206
+ function_config = tool_config.get("functionCallingConfig")
207
+ if not isinstance(function_config, dict):
208
+ function_config = {}
209
+ tool_config["functionCallingConfig"] = function_config
210
+
211
+ function_config["mode"] = "VALIDATED"
212
+
213
+ return payload
214
+
215
+
216
+ def _is_retryable_status(status_code: int, disable_error_codes: List[int]) -> bool:
217
+ """统一判断是否属于可重试状态码。"""
218
+ return status_code in (429, 503) or status_code in disable_error_codes
219
+
220
+
221
+ async def _switch_credential_for_retry(
222
+ *,
223
+ next_cred_task: Optional[asyncio.Task],
224
+ retry_interval: float,
225
+ refresh_credential_fast: Callable[[], Any],
226
+ apply_cred_result: Callable[[Tuple[str, Dict[str, Any]]], bool],
227
+ log_prefix: str,
228
+ ) -> Tuple[bool, Optional[asyncio.Task]]:
229
+ """优先使用预热凭证,失败后退回同步刷新。"""
230
+ if next_cred_task is not None:
231
+ try:
232
+ cred_result = await next_cred_task
233
+ next_cred_task = None
234
+ if cred_result and apply_cred_result(cred_result):
235
+ await asyncio.sleep(retry_interval)
236
+ return True, next_cred_task
237
+ except Exception as e:
238
+ log.warning(f"{log_prefix} 预热凭证任务失败: {e}")
239
+ next_cred_task = None
240
+
241
+ await asyncio.sleep(retry_interval)
242
+ if await refresh_credential_fast():
243
+ return True, next_cred_task
244
+
245
+ return False, next_cred_task
246
+
247
+
248
+ # ==================== 新的流式和非流式请求函数 ====================
249
+
250
+ async def stream_request(
251
+ body: Dict[str, Any],
252
+ native: bool = False,
253
+ headers: Optional[Dict[str, str]] = None,
254
+ ):
255
+ """
256
+ 流式请求函数
257
+
258
+ Args:
259
+ body: 请求体
260
+ native: 是否返回原生bytes流,False则返回str流
261
+ headers: 额外的请求头
262
+
263
+ Yields:
264
+ Response对象(错误时)或 bytes流/str流(成功时)
265
+ """
266
+ model_name = body.get("model", "")
267
+ switch_credential_enabled = await get_antigravity_switch_credential_enabled()
268
+
269
+ # 1. 获取有效凭证
270
+ cred_result = await credential_manager.get_valid_credential(
271
+ mode="antigravity", model_name=model_name
272
+ )
273
+
274
+ if not cred_result:
275
+ # 如果返回值是None,直接返回错误500
276
+ log.error("[ANTIGRAVITY STREAM] 当前无可用凭证")
277
+ yield Response(
278
+ content=json.dumps({"error": "当前无可用凭证"}),
279
+ status_code=500,
280
+ media_type="application/json"
281
+ )
282
+ return
283
+
284
+ current_file, credential_data = cred_result
285
+ access_token = credential_data.get("access_token") or credential_data.get("token")
286
+ project_id = credential_data.get("project_id", "")
287
+
288
+ if not access_token:
289
+ log.error(f"[ANTIGRAVITY STREAM] No access token in credential: {current_file}")
290
+ yield Response(
291
+ content=json.dumps({"error": "凭证中没有访问令牌"}),
292
+ status_code=500,
293
+ media_type="application/json"
294
+ )
295
+ return
296
+
297
+ # 2. 构建URL和请求头
298
+ antigravity_url = await get_antigravity_api_url()
299
+ target_url = f"{antigravity_url}/v1internal:streamGenerateContent?alt=sse"
300
+
301
+ auth_headers = build_antigravity_headers(access_token, model_name)
302
+
303
+ # 合并自定义headers
304
+ if headers:
305
+ auth_headers.update(headers)
306
+
307
+ # 构建包含project的payload
308
+ final_payload = {
309
+ "model": body.get("model"),
310
+ "project": project_id,
311
+ "request": body.get("request", {}),
312
+ }
313
+ _prepare_antigravity_payload(final_payload, model_name)
314
+
315
+ # 仅当凭证明确开启积分消耗时注入 enabledCreditTypes
316
+ def apply_enabled_credit_types(cred_data: Dict[str, Any]) -> None:
317
+ if cred_data.get("enable_credit") is True:
318
+ final_payload["enabledCreditTypes"] = ["GOOGLE_ONE_AI"]
319
+ else:
320
+ final_payload.pop("enabledCreditTypes", None)
321
+
322
+ apply_enabled_credit_types(credential_data)
323
+
324
+ # 3. 调用stream_post_async进行请求
325
+ retry_config = await get_retry_config()
326
+ max_retries = retry_config["max_retries"]
327
+ retry_interval = retry_config["retry_interval"]
328
+
329
+ DISABLE_ERROR_CODES = await get_auto_ban_error_codes() # 禁用凭证的错误码
330
+ last_error_response = None # 记录最后一次的错误响应
331
+ next_cred_task = None # 预热的下一个凭证任务
332
+
333
+ # 内部函数:快速更新凭证(只更新token和project_id,避免重建整个请求)
334
+ async def refresh_credential_fast():
335
+ nonlocal current_file, access_token, auth_headers, project_id, final_payload
336
+ cred_result = await credential_manager.get_valid_credential(
337
+ mode="antigravity", model_name=model_name
338
+ )
339
+ if not cred_result:
340
+ return None
341
+ current_file, credential_data = cred_result
342
+ access_token = credential_data.get("access_token") or credential_data.get("token")
343
+ project_id = credential_data.get("project_id", "")
344
+ if not access_token:
345
+ return None
346
+ # 只更新token和project_id,不重建整个headers和payload
347
+ auth_headers["Authorization"] = f"Bearer {access_token}"
348
+ final_payload["project"] = project_id
349
+ apply_enabled_credit_types(credential_data)
350
+ return True
351
+
352
+ def apply_cred_result(cred_result: Tuple[str, Dict[str, Any]]) -> bool:
353
+ nonlocal current_file, access_token, project_id, auth_headers, final_payload
354
+ current_file, credential_data = cred_result
355
+ access_token = credential_data.get("access_token") or credential_data.get("token")
356
+ project_id = credential_data.get("project_id", "")
357
+ if not access_token or not project_id:
358
+ return False
359
+ auth_headers["Authorization"] = f"Bearer {access_token}"
360
+ final_payload["project"] = project_id
361
+ apply_enabled_credit_types(credential_data)
362
+ return True
363
+
364
+ for attempt in range(max_retries + 1):
365
+ success_recorded = False # 标记是否已记录成功
366
+ need_retry = False # 标记是否需要重试
367
+ should_force_switch = False
368
+
369
+ try:
370
+ async for chunk in stream_post_async(
371
+ url=target_url,
372
+ body=final_payload,
373
+ native=native,
374
+ headers=auth_headers
375
+ ):
376
+ # 判断是否是Response对象
377
+ if isinstance(chunk, Response):
378
+ status_code = chunk.status_code
379
+ last_error_response = chunk # 记录最后一次错误
380
+
381
+ # 缓存错误解析结果,避免重复decode
382
+ error_body = None
383
+ try:
384
+ error_body = chunk.body.decode('utf-8') if isinstance(chunk.body, bytes) else str(chunk.body)
385
+ except Exception:
386
+ error_body = ""
387
+
388
+ # 如果错误码是429、503或者在禁用码当中,做好记录后进行重试
389
+ if _is_retryable_status(status_code, DISABLE_ERROR_CODES):
390
+ log.warning(f"[ANTIGRAVITY STREAM] 流式请求失败 (status={status_code}), 凭证: {current_file}, 响应: {error_body[:500] if error_body else '无'}")
391
+
392
+ # 解析冷却时间
393
+ cooldown_until = None
394
+ if (status_code == 429 or status_code == 503) and error_body:
395
+ try:
396
+ cooldown_until = await parse_and_log_cooldown(error_body, mode="antigravity")
397
+ except Exception:
398
+ pass
399
+
400
+ if cooldown_until is not None:
401
+ should_force_switch = True
402
+ elif await check_should_auto_ban(status_code):
403
+ should_force_switch = True
404
+
405
+ # 预热下一个凭证
406
+ if (switch_credential_enabled or should_force_switch) and next_cred_task is None and attempt < max_retries:
407
+ next_cred_task = asyncio.create_task(
408
+ credential_manager.get_valid_credential(
409
+ mode="antigravity", model_name=model_name
410
+ )
411
+ )
412
+
413
+ # 记录错误并切换凭证
414
+ await record_api_call_error(
415
+ credential_manager, current_file, status_code,
416
+ cooldown_until, mode="antigravity", model_name=model_name,
417
+ error_message=error_body
418
+ )
419
+
420
+ # 检查是否应该重试
421
+ should_retry = await handle_error_with_retry(
422
+ credential_manager, status_code, current_file,
423
+ retry_config["retry_enabled"], attempt, max_retries, retry_interval,
424
+ mode="antigravity"
425
+ )
426
+
427
+ if should_retry and attempt < max_retries:
428
+ need_retry = True
429
+ break # 跳出内层循环,准备重试
430
+ else:
431
+ # 不重试,直接返回原始错误
432
+ log.error(f"[ANTIGRAVITY STREAM] 达到最大重试次数或不应重试,返回原始错误")
433
+ yield chunk
434
+ return
435
+ else:
436
+ # 错误码不在禁用码当中,直接返回,无需重试
437
+ log.error(f"[ANTIGRAVITY STREAM] 流式请求失败,非重试错误码 (status={status_code}), 凭证: {current_file}, 响应: {error_body[:500] if error_body else '无'}")
438
+ await record_api_call_error(
439
+ credential_manager, current_file, status_code,
440
+ None, mode="antigravity", model_name=model_name,
441
+ error_message=error_body
442
+ )
443
+ yield chunk
444
+ return
445
+ else:
446
+ # 不是Response,说明是真流,直接yield返回
447
+ # 只在第一个chunk时记录成功
448
+ if not success_recorded:
449
+ await record_api_call_success(
450
+ credential_manager, current_file, mode="antigravity", model_name=model_name
451
+ )
452
+ success_recorded = True
453
+ log.debug(f"[ANTIGRAVITY STREAM] 开始接收流式响应,模型: {model_name}")
454
+
455
+ # 记录原始chunk内容(用于调试)
456
+ if isinstance(chunk, bytes):
457
+ log.debug(f"[ANTIGRAVITY STREAM RAW] chunk(bytes): {chunk}")
458
+ else:
459
+ log.debug(f"[ANTIGRAVITY STREAM RAW] chunk(str): {chunk}")
460
+
461
+ yield chunk
462
+
463
+ # 流式请求完成,检查结果
464
+ if success_recorded:
465
+ log.debug(f"[ANTIGRAVITY STREAM] 流式响应完成,模型: {model_name}")
466
+ return
467
+ elif not need_retry:
468
+ # 没有收到任何数据(空回复),需要重试
469
+ log.warning(f"[ANTIGRAVITY STREAM] 收到空回复,无任何内容,凭证: {current_file}")
470
+ await record_api_call_error(
471
+ credential_manager, current_file, 200,
472
+ None, mode="antigravity", model_name=model_name,
473
+ error_message="Empty response from API"
474
+ )
475
+
476
+ if attempt < max_retries:
477
+ need_retry = True
478
+ else:
479
+ log.error(f"[ANTIGRAVITY STREAM] 空回复达到最大重试次数")
480
+ yield Response(
481
+ content=json.dumps({"error": "服务返回空回复"}),
482
+ status_code=500,
483
+ media_type="application/json"
484
+ )
485
+ return
486
+
487
+ # 统一处理重试
488
+ if need_retry:
489
+ log.info(f"[ANTIGRAVITY STREAM] 重试请求 (attempt {attempt + 2}/{max_retries + 1})...")
490
+
491
+ if switch_credential_enabled or should_force_switch:
492
+ switched, next_cred_task = await _switch_credential_for_retry(
493
+ next_cred_task=next_cred_task,
494
+ retry_interval=retry_interval,
495
+ refresh_credential_fast=refresh_credential_fast,
496
+ apply_cred_result=apply_cred_result,
497
+ log_prefix="[ANTIGRAVITY STREAM]",
498
+ )
499
+ if not switched:
500
+ log.error("[ANTIGRAVITY STREAM] 重试时无可用凭证或令牌")
501
+ yield Response(
502
+ content=json.dumps({"error": "当前无可用凭证"}),
503
+ status_code=500,
504
+ media_type="application/json"
505
+ )
506
+ return
507
+ else:
508
+ await asyncio.sleep(retry_interval)
509
+ continue # 重试
510
+
511
+ except Exception as e:
512
+ log.error(f"[ANTIGRAVITY STREAM] 流式请求异常: {e}, 凭证: {current_file}")
513
+ if attempt < max_retries:
514
+ log.info(f"[ANTIGRAVITY STREAM] 异常后重试 (attempt {attempt + 2}/{max_retries + 1})...")
515
+ await asyncio.sleep(retry_interval)
516
+ continue
517
+ else:
518
+ # 所有重试都失败,返回最后一次的错误(如果有)
519
+ log.error(f"[ANTIGRAVITY STREAM] 所有重试均失败,最后异常: {e}")
520
+ if last_error_response:
521
+ yield last_error_response
522
+ else:
523
+ # 如果没有记录到错误响应,返回500错误
524
+ yield Response(
525
+ content=json.dumps({"error": f"流式请求异常: {str(e)}"}),
526
+ status_code=500,
527
+ media_type="application/json"
528
+ )
529
+ return
530
+
531
+ # 所有重试均已耗尽(for循环正常结束),返回最后记录的错误
532
+ log.error("[ANTIGRAVITY STREAM] 所有重试均失败")
533
+ if last_error_response:
534
+ yield last_error_response
535
+ else:
536
+ yield Response(
537
+ content=json.dumps({"error": "请求失败,所有重试均已耗尽"}),
538
+ status_code=429,
539
+ media_type="application/json"
540
+ )
541
+
542
+
543
+ async def non_stream_request(
544
+ body: Dict[str, Any],
545
+ headers: Optional[Dict[str, str]] = None,
546
+ ) -> Response:
547
+ """
548
+ 非流式请求函数
549
+
550
+ Args:
551
+ body: 请求体
552
+ headers: 额外的请求头
553
+
554
+ Returns:
555
+ Response对象
556
+ """
557
+ # 检查是否启用流式收集模式
558
+ if await get_antigravity_stream2nostream():
559
+ log.debug("[ANTIGRAVITY] 使用流式收集模式实现非流式请求")
560
+
561
+ # 调用stream_request获取流
562
+ stream = stream_request(body=body, native=False, headers=headers)
563
+
564
+ # 收集流式响应
565
+ # stream_request是一个异步生成器,可能yield Response(错误)或流数据
566
+ # collect_streaming_response会自动处理这两种情况
567
+ return await collect_streaming_response(stream)
568
+
569
+ # 否则使用传统非流式模式
570
+ log.debug("[ANTIGRAVITY] 使用传统非流式模式")
571
+
572
+ model_name = body.get("model", "")
573
+ switch_credential_enabled = await get_antigravity_switch_credential_enabled()
574
+
575
+ # 1. 获取有效凭证
576
+ cred_result = await credential_manager.get_valid_credential(
577
+ mode="antigravity", model_name=model_name
578
+ )
579
+
580
+ if not cred_result:
581
+ # 如果返回值是None,直接返回错误500
582
+ log.error("[ANTIGRAVITY] 当前无可用凭证")
583
+ return Response(
584
+ content=json.dumps({"error": "当前无可用凭证"}),
585
+ status_code=500,
586
+ media_type="application/json"
587
+ )
588
+
589
+ current_file, credential_data = cred_result
590
+ access_token = credential_data.get("access_token") or credential_data.get("token")
591
+ project_id = credential_data.get("project_id", "")
592
+
593
+ if not access_token:
594
+ log.error(f"[ANTIGRAVITY] No access token in credential: {current_file}")
595
+ return Response(
596
+ content=json.dumps({"error": "凭证中没有访问令牌"}),
597
+ status_code=500,
598
+ media_type="application/json"
599
+ )
600
+
601
+ # 2. 构建URL和请求头
602
+ antigravity_url = await get_antigravity_api_url()
603
+ target_url = f"{antigravity_url}/v1internal:generateContent"
604
+
605
+ auth_headers = build_antigravity_headers(access_token, model_name)
606
+
607
+ # 合并自定义headers
608
+ if headers:
609
+ auth_headers.update(headers)
610
+
611
+ # 构建包含project的payload
612
+ final_payload = {
613
+ "model": body.get("model"),
614
+ "project": project_id,
615
+ "request": body.get("request", {}),
616
+ }
617
+ _prepare_antigravity_payload(final_payload, model_name)
618
+
619
+ # 仅当凭证明确开启积分消耗时注入 enabledCreditTypes
620
+ def apply_enabled_credit_types(cred_data: Dict[str, Any]) -> None:
621
+ if cred_data.get("enable_credit") is True:
622
+ final_payload["enabledCreditTypes"] = ["GOOGLE_ONE_AI"]
623
+ else:
624
+ final_payload.pop("enabledCreditTypes", None)
625
+
626
+ apply_enabled_credit_types(credential_data)
627
+
628
+ # 3. 调用post_async进行请求
629
+ retry_config = await get_retry_config()
630
+ max_retries = retry_config["max_retries"]
631
+ retry_interval = retry_config["retry_interval"]
632
+
633
+ DISABLE_ERROR_CODES = await get_auto_ban_error_codes() # 禁用凭证的错误码
634
+ last_error_response = None # 记录最后一次的错误响应
635
+ next_cred_task = None # 预热的下一个凭证任务
636
+
637
+ # 内部函数:快速更新凭证(只更新token和project_id,避免重建整个请求)
638
+ async def refresh_credential_fast():
639
+ nonlocal current_file, access_token, auth_headers, project_id, final_payload
640
+ cred_result = await credential_manager.get_valid_credential(
641
+ mode="antigravity", model_name=model_name
642
+ )
643
+ if not cred_result:
644
+ return None
645
+ current_file, credential_data = cred_result
646
+ access_token = credential_data.get("access_token") or credential_data.get("token")
647
+ project_id = credential_data.get("project_id", "")
648
+ if not access_token:
649
+ return None
650
+ # 只更新token和project_id,不重建整个headers和payload
651
+ auth_headers["Authorization"] = f"Bearer {access_token}"
652
+ final_payload["project"] = project_id
653
+ apply_enabled_credit_types(credential_data)
654
+ return True
655
+
656
+ def apply_cred_result(cred_result: Tuple[str, Dict[str, Any]]) -> bool:
657
+ nonlocal current_file, access_token, project_id, auth_headers, final_payload
658
+ current_file, credential_data = cred_result
659
+ access_token = credential_data.get("access_token") or credential_data.get("token")
660
+ project_id = credential_data.get("project_id", "")
661
+ if not access_token or not project_id:
662
+ return False
663
+ auth_headers["Authorization"] = f"Bearer {access_token}"
664
+ final_payload["project"] = project_id
665
+ apply_enabled_credit_types(credential_data)
666
+ return True
667
+
668
+ for attempt in range(max_retries + 1):
669
+ need_retry = False # 标记是否需要重试
670
+ should_force_switch = False
671
+
672
+ try:
673
+ response = await post_async(
674
+ url=target_url,
675
+ json=final_payload,
676
+ headers=auth_headers,
677
+ timeout=300.0
678
+ )
679
+
680
+ status_code = response.status_code
681
+
682
+ # 成功
683
+ if status_code == 200:
684
+ # 检查是否为空回复
685
+ if not response.content or len(response.content) == 0:
686
+ log.warning(f"[ANTIGRAVITY] 收到200响应但内容为空,凭证: {current_file}")
687
+
688
+ # 记录错误
689
+ await record_api_call_error(
690
+ credential_manager, current_file, 200,
691
+ None, mode="antigravity", model_name=model_name,
692
+ error_message="Empty response from API"
693
+ )
694
+
695
+ if attempt < max_retries:
696
+ need_retry = True
697
+ else:
698
+ log.error(f"[ANTIGRAVITY] 空回复达到最大重试次数")
699
+ return Response(
700
+ content=json.dumps({"error": "服务返回空回复"}),
701
+ status_code=500,
702
+ media_type="application/json"
703
+ )
704
+ else:
705
+ # 正常响应
706
+ await record_api_call_success(
707
+ credential_manager, current_file, mode="antigravity", model_name=model_name
708
+ )
709
+ return Response(
710
+ content=response.content,
711
+ status_code=200,
712
+ headers=dict(response.headers)
713
+ )
714
+
715
+ # 失败 - 记录最后一次错误
716
+ if status_code != 200:
717
+ last_error_response = Response(
718
+ content=response.content,
719
+ status_code=status_code,
720
+ headers=dict(response.headers)
721
+ )
722
+
723
+ # 判断是否需要重试
724
+ # 缓存错误文本,避免重复解析
725
+ error_text = ""
726
+ try:
727
+ error_text = response.text
728
+ except Exception:
729
+ pass
730
+
731
+ if _is_retryable_status(status_code, DISABLE_ERROR_CODES):
732
+ log.warning(f"[ANTIGRAVITY] 非流式请求失败 (status={status_code}), 凭证: {current_file}, 响应: {error_text[:500] if error_text else '无'}")
733
+
734
+ # 解析冷却时间
735
+ cooldown_until = None
736
+ if (status_code == 429 or status_code == 503) and error_text:
737
+ try:
738
+ cooldown_until = await parse_and_log_cooldown(error_text, mode="antigravity")
739
+ except Exception:
740
+ pass
741
+
742
+ if cooldown_until is not None:
743
+ should_force_switch = True
744
+ elif await check_should_auto_ban(status_code):
745
+ should_force_switch = True
746
+
747
+ # 并行预热下一个凭证,不阻塞当前处理
748
+ if (switch_credential_enabled or should_force_switch) and next_cred_task is None and attempt < max_retries:
749
+ next_cred_task = asyncio.create_task(
750
+ credential_manager.get_valid_credential(
751
+ mode="antigravity", model_name=model_name
752
+ )
753
+ )
754
+
755
+ # 记录错误并切换凭证
756
+ await record_api_call_error(
757
+ credential_manager, current_file, status_code,
758
+ cooldown_until, mode="antigravity", model_name=model_name,
759
+ error_message=error_text
760
+ )
761
+
762
+ # 检查是否应该重试
763
+ should_retry = await handle_error_with_retry(
764
+ credential_manager, status_code, current_file,
765
+ retry_config["retry_enabled"], attempt, max_retries, retry_interval,
766
+ mode="antigravity"
767
+ )
768
+
769
+ if should_retry and attempt < max_retries:
770
+ need_retry = True
771
+ else:
772
+ # 不重试,直接返回原始错误
773
+ log.error(f"[ANTIGRAVITY] 达到最大重试次数或不应重试,返回原始错误")
774
+ return last_error_response
775
+ else:
776
+ # 错误码不在禁用码当中,直接返回,无需重试
777
+ log.error(f"[ANTIGRAVITY] 非流式请求失败,非重试错误码 (status={status_code}), 凭证: {current_file}, 响应: {error_text[:500] if error_text else '无'}")
778
+ await record_api_call_error(
779
+ credential_manager, current_file, status_code,
780
+ None, mode="antigravity", model_name=model_name,
781
+ error_message=error_text
782
+ )
783
+ return last_error_response
784
+
785
+ # 统一处理重试
786
+ if need_retry:
787
+ log.info(f"[ANTIGRAVITY] 重试请求 (attempt {attempt + 2}/{max_retries + 1})...")
788
+
789
+ if switch_credential_enabled or should_force_switch:
790
+ switched, next_cred_task = await _switch_credential_for_retry(
791
+ next_cred_task=next_cred_task,
792
+ retry_interval=retry_interval,
793
+ refresh_credential_fast=refresh_credential_fast,
794
+ apply_cred_result=apply_cred_result,
795
+ log_prefix="[ANTIGRAVITY]",
796
+ )
797
+ if not switched:
798
+ log.error("[ANTIGRAVITY] 重试时无可用凭证或令牌")
799
+ return Response(
800
+ content=json.dumps({"error": "当前无可用凭证"}),
801
+ status_code=500,
802
+ media_type="application/json"
803
+ )
804
+ else:
805
+ await asyncio.sleep(retry_interval)
806
+ continue # 重试
807
+
808
+ except Exception as e:
809
+ log.error(f"[ANTIGRAVITY] 非流式请求异常: {e}, 凭证: {current_file}")
810
+ if attempt < max_retries:
811
+ log.info(f"[ANTIGRAVITY] 异常后重试 (attempt {attempt + 2}/{max_retries + 1})...")
812
+ await asyncio.sleep(retry_interval)
813
+ continue
814
+ else:
815
+ # 所有重试都失败,返回最后一次的错误(如果有)或500错误
816
+ log.error(f"[ANTIGRAVITY] 所有重试均失败,最后异常: {e}")
817
+ if last_error_response:
818
+ return last_error_response
819
+ else:
820
+ return Response(
821
+ content=json.dumps({"error": f"非流式请求异常: {str(e)}"}),
822
+ status_code=500,
823
+ media_type="application/json"
824
+ )
825
+
826
+ # 所有重试都失败,返回最后一次的原始错误(如果有)或500错误
827
+ log.error("[ANTIGRAVITY] 所有重试均失败")
828
+ if last_error_response:
829
+ return last_error_response
830
+ else:
831
+ return Response(
832
+ content=json.dumps({"error": "所有重试均失败"}),
833
+ status_code=500,
834
+ media_type="application/json"
835
+ )
836
+
837
+
838
+ # ==================== 模型和配额查询 ====================
839
+
840
+ async def fetch_available_models() -> List[Dict[str, Any]]:
841
+ """
842
+ 获取可用模型列表,返回符合 OpenAI API 规范的格式
843
+
844
+ Returns:
845
+ 模型列表,格式为字典列表(用于兼容现有代码)
846
+
847
+ Raises:
848
+ 返回空列表如果获取失败
849
+ """
850
+ # 获取凭证管理器和可用凭证
851
+ cred_result = await credential_manager.get_valid_credential(mode="antigravity")
852
+ if not cred_result:
853
+ log.error("[ANTIGRAVITY] No valid credentials available for fetching models")
854
+ return []
855
+
856
+ current_file, credential_data = cred_result
857
+ access_token = credential_data.get("access_token") or credential_data.get("token")
858
+
859
+ if not access_token:
860
+ log.error(f"[ANTIGRAVITY] No access token in credential: {current_file}")
861
+ return []
862
+
863
+ # 构建请求头
864
+ headers = build_antigravity_headers(access_token)
865
+
866
+ try:
867
+ # 使用 POST 请求获取模型列表
868
+ antigravity_url = await get_antigravity_api_url()
869
+
870
+ response = await post_async(
871
+ url=f"{antigravity_url}/v1internal:fetchAvailableModels",
872
+ json={}, # 空的请求体
873
+ headers=headers
874
+ )
875
+
876
+ if response.status_code == 200:
877
+ data = response.json()
878
+ log.debug(f"[ANTIGRAVITY] Raw models response: {json.dumps(data, ensure_ascii=False)[:500]}")
879
+
880
+ # 转换为 OpenAI 格式的模型列表,使用 Model 类
881
+ model_list = []
882
+ current_timestamp = int(datetime.now(timezone.utc).timestamp())
883
+
884
+ if 'models' in data and isinstance(data['models'], dict):
885
+ # 遍历模型字典
886
+ for model_id in data['models'].keys():
887
+ model = Model(
888
+ id=model_id,
889
+ object='model',
890
+ created=current_timestamp,
891
+ owned_by='google'
892
+ )
893
+ model_list.append(model_to_dict(model))
894
+ # 添加额外的 claude-sonnet-4-6-thinking 模型
895
+ if "claude-sonnet-4-6" in data.get('models', {}):
896
+ model = Model(
897
+ id='claude-sonnet-4-6-thinking',
898
+ object='model',
899
+ created=current_timestamp,
900
+ owned_by='google'
901
+ )
902
+ model_list.append(model_to_dict(model))
903
+ # 添加额外的 claude-opus-4-6 模型
904
+ if "claude-opus-4-6-thinking" in data.get('models', {}):
905
+ claude_opus_model = Model(
906
+ id='claude-opus-4-6',
907
+ object='model',
908
+ created=current_timestamp,
909
+ owned_by='google'
910
+ )
911
+ model_list.append(model_to_dict(claude_opus_model))
912
+
913
+ log.info(f"[ANTIGRAVITY] Fetched {len(model_list)} available models")
914
+ return model_list
915
+ else:
916
+ log.error(f"[ANTIGRAVITY] Failed to fetch models ({response.status_code}): {response.text[:500]}")
917
+ return []
918
+
919
+ except Exception as e:
920
+ import traceback
921
+ log.error(f"[ANTIGRAVITY] Failed to fetch models: {e}")
922
+ log.error(f"[ANTIGRAVITY] Traceback: {traceback.format_exc()}")
923
+ return []
924
+
925
+
926
+ async def fetch_quota_info(access_token: str) -> Dict[str, Any]:
927
+ """
928
+ 获取指定凭证的额度信息
929
+
930
+ Args:
931
+ access_token: Antigravity 访问令牌
932
+
933
+ Returns:
934
+ 包含额度信息的字典,格式为:
935
+ {
936
+ "success": True/False,
937
+ "models": {
938
+ "model_name": {
939
+ "remaining": 0.95,
940
+ "resetTime": "12-20 10:30",
941
+ "resetTimeRaw": "2025-12-20T02:30:00Z"
942
+ }
943
+ },
944
+ "error": "错误信息" (仅在失败时)
945
+ }
946
+ """
947
+
948
+ headers = build_antigravity_headers(access_token)
949
+
950
+ try:
951
+ antigravity_url = await get_antigravity_api_url()
952
+
953
+ response = await post_async(
954
+ url=f"{antigravity_url}/v1internal:fetchAvailableModels",
955
+ json={},
956
+ headers=headers,
957
+ timeout=30.0
958
+ )
959
+
960
+ if response.status_code == 200:
961
+ data = response.json()
962
+ log.debug(f"[ANTIGRAVITY QUOTA] Raw response: {json.dumps(data, ensure_ascii=False)[:500]}")
963
+
964
+ quota_info = {}
965
+
966
+ if 'models' in data and isinstance(data['models'], dict):
967
+ for model_id, model_data in data['models'].items():
968
+ if isinstance(model_data, dict) and 'quotaInfo' in model_data:
969
+ quota = model_data['quotaInfo']
970
+ remaining = quota.get('remainingFraction', 0)
971
+ reset_time_raw = quota.get('resetTime', '')
972
+
973
+ # 转换为北京时间
974
+ reset_time_beijing = 'N/A'
975
+ if reset_time_raw:
976
+ try:
977
+ utc_date = datetime.fromisoformat(reset_time_raw.replace('Z', '+00:00'))
978
+ # 转换为北京时间 (UTC+8)
979
+ from datetime import timedelta
980
+ beijing_date = utc_date + timedelta(hours=8)
981
+ reset_time_beijing = beijing_date.strftime('%m-%d %H:%M')
982
+ except Exception as e:
983
+ log.warning(f"[ANTIGRAVITY QUOTA] Failed to parse reset time: {e}")
984
+
985
+ quota_info[model_id] = {
986
+ "remaining": remaining,
987
+ "resetTime": reset_time_beijing,
988
+ "resetTimeRaw": reset_time_raw
989
+ }
990
+
991
+ return {
992
+ "success": True,
993
+ "models": quota_info
994
+ }
995
+ else:
996
+ log.error(f"[ANTIGRAVITY QUOTA] Failed to fetch quota ({response.status_code}): {response.text[:500]}")
997
+ return {
998
+ "success": False,
999
+ "error": f"API返回错误: {response.status_code}"
1000
+ }
1001
+
1002
+ except Exception as e:
1003
+ import traceback
1004
+ log.error(f"[ANTIGRAVITY QUOTA] Failed to fetch quota: {e}")
1005
+ log.error(f"[ANTIGRAVITY QUOTA] Traceback: {traceback.format_exc()}")
1006
+ return {
1007
+ "success": False,
1008
+ "error": str(e)
1009
+ }
src/api/geminicli.py ADDED
@@ -0,0 +1,808 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ GeminiCli API Client - Handles all communication with GeminiCli API.
3
+ This module is used by both OpenAI compatibility layer and native Gemini endpoints.
4
+ GeminiCli API 客户端 - 处理与 GeminiCli API 的所有通信
5
+ """
6
+
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ # 添加项目根目录到Python路径(用于直接运行测试)
11
+ if __name__ == "__main__":
12
+ project_root = Path(__file__).resolve().parent.parent.parent
13
+ if str(project_root) not in sys.path:
14
+ sys.path.insert(0, str(project_root))
15
+
16
+ import asyncio
17
+ import json
18
+ from typing import Any, Dict, Optional, Callable, Tuple
19
+
20
+ from fastapi import Response
21
+ from config import get_code_assist_endpoint, get_auto_ban_error_codes
22
+ from log import log
23
+
24
+ from src.credential_manager import credential_manager
25
+ from src.httpx_client import stream_post_async, post_async
26
+
27
+ # 导入共同的基础功能
28
+ from src.api.utils import (
29
+ handle_error_with_retry,
30
+ get_retry_config,
31
+ record_api_call_success,
32
+ record_api_call_error,
33
+ parse_and_log_cooldown,
34
+ )
35
+ from src.utils import get_geminicli_user_agent
36
+
37
+ # ==================== 全局凭证管理器 ====================
38
+
39
+ # 使用全局单例 credential_manager,自动初始化
40
+
41
+
42
+ # ==================== 请求准备 ====================
43
+
44
+ async def prepare_request_headers_and_payload(
45
+ payload: dict, credential_data: dict, target_url: str
46
+ ):
47
+ """
48
+ 从凭证数据准备请求头和最终payload
49
+
50
+ Args:
51
+ payload: 原始请求payload
52
+ credential_data: 凭证数据字典
53
+ target_url: 目标URL
54
+
55
+ Returns:
56
+ 元组: (headers, final_payload, target_url)
57
+
58
+ Raises:
59
+ Exception: 如果凭证中缺少必要字段
60
+ """
61
+ token = credential_data.get("token") or credential_data.get("access_token", "")
62
+ if not token:
63
+ raise Exception("凭证中没有找到有效的访问令牌(token或access_token字段)")
64
+
65
+ source_request = payload.get("request", {})
66
+
67
+ # 内部API使用Bearer Token和项目ID
68
+ headers = {
69
+ "Authorization": f"Bearer {token}",
70
+ "Content-Type": "application/json",
71
+ "User-Agent": get_geminicli_user_agent(payload.get("model", "")),
72
+ }
73
+ project_id = credential_data.get("project_id", "")
74
+ if not project_id:
75
+ raise Exception("项目ID不存在于凭证数据中")
76
+ final_payload = {
77
+ "model": payload.get("model"),
78
+ "project": project_id,
79
+ "request": source_request,
80
+ }
81
+
82
+ return headers, final_payload, target_url
83
+
84
+
85
+ def _is_retryable_status(status_code: int, disable_error_codes: list[int]) -> bool:
86
+ """统一判断是否属于可重试状态码。"""
87
+ return status_code in (429, 500, 503) or status_code in disable_error_codes
88
+
89
+
90
+ async def _switch_credential_for_retry(
91
+ *,
92
+ next_cred_task: Optional[asyncio.Task],
93
+ retry_interval: float,
94
+ refresh_credential_fast: Callable[[], Any],
95
+ apply_cred_result: Callable[[Tuple[str, Dict[str, Any]]], bool],
96
+ log_prefix: str,
97
+ ) -> Tuple[bool, Optional[asyncio.Task]]:
98
+ """优先使用预热凭证,失败后退回同步刷新。"""
99
+ if next_cred_task is not None:
100
+ try:
101
+ cred_result = await next_cred_task
102
+ next_cred_task = None
103
+ if cred_result and apply_cred_result(cred_result):
104
+ await asyncio.sleep(retry_interval)
105
+ return True, next_cred_task
106
+ except Exception as e:
107
+ log.warning(f"{log_prefix} 预热凭证任务失败: {e}")
108
+ next_cred_task = None
109
+
110
+ await asyncio.sleep(retry_interval)
111
+ if await refresh_credential_fast():
112
+ return True, next_cred_task
113
+
114
+ return False, next_cred_task
115
+
116
+
117
+ # ==================== 新的流式和非流式请求函数 ====================
118
+
119
+ async def stream_request(
120
+ body: Dict[str, Any],
121
+ native: bool = False,
122
+ headers: Optional[Dict[str, str]] = None,
123
+ ):
124
+ """
125
+ 流式请求函数
126
+
127
+ Args:
128
+ body: 请求体
129
+ native: 是否返回原生bytes流,False则返回str流
130
+ headers: 额外的请求头
131
+
132
+ Yields:
133
+ Response对象(错误时)或 bytes流/str流(成功时)
134
+ """
135
+ # 获取有效凭证
136
+ model_name = body.get("model", "")
137
+
138
+ # 1. 获取有效凭证
139
+ cred_result = await credential_manager.get_valid_credential(
140
+ mode="geminicli", model_name=model_name
141
+ )
142
+
143
+ if not cred_result:
144
+ # 如果返回值是None,直接返回错误500
145
+ yield Response(
146
+ content=json.dumps({"error": "当前无可用凭证"}),
147
+ status_code=500,
148
+ media_type="application/json"
149
+ )
150
+ return
151
+
152
+ current_file, credential_data = cred_result
153
+
154
+ # 2. 构建URL和请求头
155
+ try:
156
+ auth_headers, final_payload, target_url = await prepare_request_headers_and_payload(
157
+ body, credential_data,
158
+ f"{await get_code_assist_endpoint()}/v1internal:streamGenerateContent?alt=sse"
159
+ )
160
+
161
+ # 合并自定义headers
162
+ if headers:
163
+ auth_headers.update(headers)
164
+
165
+ except Exception as e:
166
+ log.error(f"准备请求失败: {e}")
167
+ yield Response(
168
+ content=json.dumps({"error": f"准备请求失败: {str(e)}"}),
169
+ status_code=500,
170
+ media_type="application/json"
171
+ )
172
+ return
173
+
174
+ # 3. 调用stream_post_async进行请求
175
+ retry_config = await get_retry_config()
176
+ max_retries = retry_config["max_retries"]
177
+ retry_interval = retry_config["retry_interval"]
178
+
179
+ DISABLE_ERROR_CODES = await get_auto_ban_error_codes() # 禁用凭证的错误码
180
+ last_error_response = None # 记录最后一次的错误响应
181
+ next_cred_task = None # 预热的下一个凭证任务
182
+
183
+ # 内部函数:快速更新凭证(只更新token和project_id,避免重建整个请求)
184
+ async def refresh_credential_fast():
185
+ nonlocal current_file, credential_data, auth_headers, final_payload
186
+ cred_result = await credential_manager.get_valid_credential(
187
+ mode="geminicli", model_name=model_name
188
+ )
189
+ if not cred_result:
190
+ return None
191
+ current_file, credential_data = cred_result
192
+ try:
193
+ # 只更新token和project_id,不重建整个headers和payload
194
+ token = credential_data.get("token") or credential_data.get("access_token", "")
195
+ project_id = credential_data.get("project_id", "")
196
+ if not token or not project_id:
197
+ return None
198
+
199
+ # 直接更新现有的headers和payload
200
+ auth_headers["Authorization"] = f"Bearer {token}"
201
+ final_payload["project"] = project_id
202
+ return True
203
+ except Exception:
204
+ return None
205
+
206
+ def apply_cred_result(cred_result: Tuple[str, Dict[str, Any]]) -> bool:
207
+ nonlocal current_file, credential_data, auth_headers, final_payload
208
+ current_file, credential_data = cred_result
209
+ token = credential_data.get("token") or credential_data.get("access_token", "")
210
+ project_id = credential_data.get("project_id", "")
211
+ if not token or not project_id:
212
+ return False
213
+ auth_headers["Authorization"] = f"Bearer {token}"
214
+ final_payload["project"] = project_id
215
+ return True
216
+
217
+ for attempt in range(max_retries + 1):
218
+ success_recorded = False # 标记是否已记录成功
219
+ need_retry = False # 标记是否需要重试
220
+
221
+ try:
222
+ async for chunk in stream_post_async(
223
+ url=target_url,
224
+ body=final_payload,
225
+ native=native,
226
+ headers=auth_headers
227
+ ):
228
+ # 判断是否是Response对象
229
+ if isinstance(chunk, Response):
230
+ status_code = chunk.status_code
231
+ last_error_response = chunk # 记录最后一次错误
232
+
233
+ # 缓存错误解析结果,避免重复decode
234
+ error_body = None
235
+ try:
236
+ error_body = chunk.body.decode('utf-8') if isinstance(chunk.body, bytes) else str(chunk.body)
237
+ except Exception:
238
+ error_body = ""
239
+
240
+ # 如果错误码是429、503或者在禁用码当中,做好记录后进行重试
241
+ if _is_retryable_status(status_code, DISABLE_ERROR_CODES):
242
+ log.warning(f"[GEMINICLI STREAM] 流式请求失败 (status={status_code}), 凭证: {current_file}, 响应: {error_body[:500] if error_body else '无'}")
243
+
244
+ # 解析冷却时间
245
+ cooldown_until = None
246
+ if (status_code == 429 or status_code == 503) and error_body:
247
+ try:
248
+ cooldown_until = await parse_and_log_cooldown(error_body, mode="geminicli")
249
+ except Exception:
250
+ pass
251
+
252
+ # 预热下一个凭证
253
+ if next_cred_task is None and attempt < max_retries:
254
+ next_cred_task = asyncio.create_task(
255
+ credential_manager.get_valid_credential(
256
+ mode="geminicli", model_name=model_name
257
+ )
258
+ )
259
+
260
+ # 记录错误并切换凭证
261
+ await record_api_call_error(
262
+ credential_manager, current_file, status_code,
263
+ cooldown_until, mode="geminicli", model_name=model_name,
264
+ error_message=error_body
265
+ )
266
+
267
+ # 检查是否应该重试
268
+ should_retry = await handle_error_with_retry(
269
+ credential_manager, status_code, current_file,
270
+ retry_config["retry_enabled"], attempt, max_retries, retry_interval,
271
+ mode="geminicli"
272
+ )
273
+
274
+ if should_retry and attempt < max_retries:
275
+ need_retry = True
276
+ break # 跳出内层循环,准备重试
277
+ else:
278
+ # 不重试,直接返回原始错误
279
+ log.error(f"[GEMINICLI STREAM] 达到最大重试次数或不应重试,返回原始错误")
280
+ yield chunk
281
+ return
282
+ elif status_code == 404 and "preview" in model_name.lower():
283
+ # 特殊处理:preview模型返回404,说明该凭证不支持preview模型
284
+ log.warning(f"[GEMINICLI STREAM] Preview模型404错误,凭证不支持preview: {current_file}")
285
+
286
+ # 将该凭证的preview状态设置为False
287
+ try:
288
+ await credential_manager.update_credential_state(
289
+ current_file, {"preview": False}, mode="geminicli"
290
+ )
291
+ log.info(f"[GEMINICLI STREAM] 已将凭证 {current_file} 的preview状态设置为False")
292
+ except Exception as e:
293
+ log.error(f"[GEMINICLI STREAM] 更新凭证preview状态失败: {e}")
294
+
295
+ # 记录404错误
296
+ await record_api_call_error(
297
+ credential_manager, current_file, status_code,
298
+ None, mode="geminicli", model_name=model_name,
299
+ error_message=error_body
300
+ )
301
+
302
+ # 预热下一个凭证(会自动跳过preview=False的凭证)
303
+ if next_cred_task is None and attempt < max_retries:
304
+ next_cred_task = asyncio.create_task(
305
+ credential_manager.get_valid_credential(
306
+ mode="geminicli", model_name=model_name
307
+ )
308
+ )
309
+
310
+ # 触发重试
311
+ if attempt < max_retries:
312
+ need_retry = True
313
+ break
314
+ else:
315
+ log.error(f"[GEMINICLI STREAM] 达到最大重试次数,返回404错误")
316
+ yield chunk
317
+ return
318
+ else:
319
+ # 错误码不在禁用码当中,直接返回,无需重试
320
+ log.error(f"[GEMINICLI STREAM] 流式请求失败,非重试错误码 (status={status_code}), 凭证: {current_file}, 响应: {error_body[:500] if error_body else '无'}")
321
+ await record_api_call_error(
322
+ credential_manager, current_file, status_code,
323
+ None, mode="geminicli", model_name=model_name,
324
+ error_message=error_body
325
+ )
326
+ yield chunk
327
+ return
328
+ else:
329
+ # 不是Response,说明是真流,直接yield返回
330
+ # 只在第一个chunk时记录成功
331
+ if not success_recorded:
332
+ await record_api_call_success(
333
+ credential_manager, current_file, mode="geminicli", model_name=model_name
334
+ )
335
+ success_recorded = True
336
+ log.debug(f"[GEMINICLI STREAM] 开始接收流式响应,模型: {model_name}")
337
+
338
+ yield chunk
339
+
340
+ # 流式请求完成,检查结果
341
+ if success_recorded:
342
+ log.debug(f"[GEMINICLI STREAM] 流式响应完成,模型: {model_name}")
343
+ return
344
+
345
+ # 统一处理重试
346
+ if need_retry:
347
+ # 如果已经是最后一次尝试,不再重试,直接返回错误
348
+ if attempt >= max_retries:
349
+ log.error(f"[GEMINICLI STREAM] 达到最大重试次数,返回错误")
350
+ if last_error_response:
351
+ yield last_error_response
352
+ else:
353
+ yield Response(
354
+ content=json.dumps({"error": "请求失败,所有重试均已耗尽"}),
355
+ status_code=429,
356
+ media_type="application/json"
357
+ )
358
+ return
359
+
360
+ log.info(f"[GEMINICLI STREAM] 重试请求 (attempt {attempt + 2}/{max_retries + 1})...")
361
+
362
+ switched, next_cred_task = await _switch_credential_for_retry(
363
+ next_cred_task=next_cred_task,
364
+ retry_interval=retry_interval,
365
+ refresh_credential_fast=refresh_credential_fast,
366
+ apply_cred_result=apply_cred_result,
367
+ log_prefix="[GEMINICLI STREAM]",
368
+ )
369
+ if not switched:
370
+ log.error("[GEMINICLI STREAM] 重试时无可用凭证或刷新失败")
371
+ yield Response(
372
+ content=json.dumps({"error": "当前无可用凭证"}),
373
+ status_code=500,
374
+ media_type="application/json"
375
+ )
376
+ return
377
+ continue # 重试
378
+
379
+ except Exception as e:
380
+ log.error(f"[GEMINICLI STREAM] 流式请求异常: {e}, 凭证: {current_file}")
381
+ if attempt < max_retries:
382
+ log.info(f"[GEMINICLI STREAM] 异常后重试 (attempt {attempt + 2}/{max_retries + 1})...")
383
+ await asyncio.sleep(retry_interval)
384
+ continue
385
+ else:
386
+ # 所有重试都失败,返回最后一次的错误(如果有)
387
+ log.error(f"[GEMINICLI STREAM] 所有重试均失败,最后异常: {e}")
388
+ if last_error_response:
389
+ yield last_error_response
390
+ else:
391
+ # 如果没有记录到错误响应,返回500错误
392
+ yield Response(
393
+ content=json.dumps({"error": f"流式请求异常: {str(e)}"}),
394
+ status_code=500,
395
+ media_type="application/json"
396
+ )
397
+ return
398
+
399
+ # 所有重试均已耗尽(for循环正常结束),返回最后记录的错误
400
+ log.error("[GEMINICLI STREAM] 所有重试均失败")
401
+ if last_error_response:
402
+ yield last_error_response
403
+ else:
404
+ yield Response(
405
+ content=json.dumps({"error": "请求失败,所有重试均已耗尽"}),
406
+ status_code=429,
407
+ media_type="application/json"
408
+ )
409
+
410
+
411
+ async def non_stream_request(
412
+ body: Dict[str, Any],
413
+ headers: Optional[Dict[str, str]] = None,
414
+ ) -> Response:
415
+ """
416
+ 非流式请求函数
417
+
418
+ Args:
419
+ body: 请求体
420
+ native: 保留参数以保持接口一致性(实际未使用)
421
+ headers: 额外的请求头
422
+
423
+ Returns:
424
+ Response对象
425
+ """
426
+ # 获取有效凭证
427
+ model_name = body.get("model", "")
428
+
429
+ # 1. 获取有效凭证
430
+ cred_result = await credential_manager.get_valid_credential(
431
+ mode="geminicli", model_name=model_name
432
+ )
433
+
434
+ if not cred_result:
435
+ # 如果返回值是None,直接返回错误500
436
+ return Response(
437
+ content=json.dumps({"error": "当前无可用凭证"}),
438
+ status_code=500,
439
+ media_type="application/json"
440
+ )
441
+
442
+ current_file, credential_data = cred_result
443
+
444
+ # 2. 构建URL和请求头
445
+ try:
446
+ auth_headers, final_payload, target_url = await prepare_request_headers_and_payload(
447
+ body, credential_data,
448
+ f"{await get_code_assist_endpoint()}/v1internal:generateContent"
449
+ )
450
+
451
+ # 合并自定义headers
452
+ if headers:
453
+ auth_headers.update(headers)
454
+
455
+ except Exception as e:
456
+ log.error(f"准备请求失败: {e}")
457
+ return Response(
458
+ content=json.dumps({"error": f"准备请求失败: {str(e)}"}),
459
+ status_code=500,
460
+ media_type="application/json"
461
+ )
462
+
463
+ # 3. 调用post_async进行请求
464
+ retry_config = await get_retry_config()
465
+ max_retries = retry_config["max_retries"]
466
+ retry_interval = retry_config["retry_interval"]
467
+
468
+ DISABLE_ERROR_CODES = await get_auto_ban_error_codes() # 禁用凭证的错误码
469
+ last_error_response = None # 记录最后一次的错误响应
470
+ next_cred_task = None # 预热的下一个凭证任务
471
+
472
+ # 内部函数:快速更新凭证(只更新token和project_id,避免重建整个请求)
473
+ async def refresh_credential_fast():
474
+ nonlocal current_file, credential_data, auth_headers, final_payload
475
+ cred_result = await credential_manager.get_valid_credential(
476
+ mode="geminicli", model_name=model_name
477
+ )
478
+ if not cred_result:
479
+ return None
480
+ current_file, credential_data = cred_result
481
+ try:
482
+ # 只更新token和project_id,不重建整个headers和payload
483
+ token = credential_data.get("token") or credential_data.get("access_token", "")
484
+ project_id = credential_data.get("project_id", "")
485
+ if not token or not project_id:
486
+ return None
487
+
488
+ # 直接更新现有的headers和payload
489
+ auth_headers["Authorization"] = f"Bearer {token}"
490
+ final_payload["project"] = project_id
491
+ return True
492
+ except Exception:
493
+ return None
494
+
495
+ def apply_cred_result(cred_result: Tuple[str, Dict[str, Any]]) -> bool:
496
+ nonlocal current_file, credential_data, auth_headers, final_payload
497
+ current_file, credential_data = cred_result
498
+ token = credential_data.get("token") or credential_data.get("access_token", "")
499
+ project_id = credential_data.get("project_id", "")
500
+ if not token or not project_id:
501
+ return False
502
+ auth_headers["Authorization"] = f"Bearer {token}"
503
+ final_payload["project"] = project_id
504
+ return True
505
+
506
+ for attempt in range(max_retries + 1):
507
+ try:
508
+ response = await post_async(
509
+ url=target_url,
510
+ json=final_payload,
511
+ headers=auth_headers,
512
+ timeout=300.0
513
+ )
514
+
515
+ status_code = response.status_code
516
+
517
+ # 成功
518
+ if status_code == 200:
519
+ await record_api_call_success(
520
+ credential_manager, current_file, mode="geminicli", model_name=model_name
521
+ )
522
+ # 创建响应头,移除压缩相关的header避免重复解压
523
+ response_headers = dict(response.headers)
524
+ response_headers.pop('content-encoding', None)
525
+ response_headers.pop('content-length', None)
526
+
527
+ return Response(
528
+ content=response.content,
529
+ status_code=200,
530
+ headers=response_headers
531
+ )
532
+
533
+ # 失败 - 记录最后一次错误
534
+ # 创建响应头,移除压缩相关的header避免重复解压
535
+ error_headers = dict(response.headers)
536
+ error_headers.pop('content-encoding', None)
537
+ error_headers.pop('content-length', None)
538
+
539
+ last_error_response = Response(
540
+ content=response.content,
541
+ status_code=status_code,
542
+ headers=error_headers
543
+ )
544
+
545
+ # 判断是否需要重试
546
+ # 缓存错误文本,避免重复解析
547
+ error_text = ""
548
+ try:
549
+ error_text = response.text
550
+ except Exception:
551
+ pass
552
+
553
+ # 统一处理所有需要重试的错误码(429、503、禁用码)
554
+ if _is_retryable_status(status_code, DISABLE_ERROR_CODES):
555
+ log.warning(f"[NON-STREAM] 非流式请求失败 (status={status_code}), 凭证: {current_file}, 响应: {error_text[:500] if error_text else '无'}")
556
+
557
+ # 解析冷却时间
558
+ cooldown_until = None
559
+ if (status_code == 429 or status_code == 503) and error_text:
560
+ try:
561
+ cooldown_until = await parse_and_log_cooldown(error_text, mode="geminicli")
562
+ except Exception:
563
+ pass
564
+
565
+ # 并行预热下一个凭证,不阻塞当前处理
566
+ if next_cred_task is None and attempt < max_retries:
567
+ next_cred_task = asyncio.create_task(
568
+ credential_manager.get_valid_credential(
569
+ mode="geminicli", model_name=model_name
570
+ )
571
+ )
572
+
573
+ # 记录错误并切换凭证
574
+ await record_api_call_error(
575
+ credential_manager, current_file, status_code,
576
+ cooldown_until, mode="geminicli", model_name=model_name,
577
+ error_message=error_text
578
+ )
579
+
580
+ # 检查是否应该重试(会自动处理禁用逻辑)
581
+ should_retry = await handle_error_with_retry(
582
+ credential_manager, status_code, current_file,
583
+ retry_config["retry_enabled"], attempt, max_retries, retry_interval,
584
+ mode="geminicli"
585
+ )
586
+
587
+ if should_retry and attempt < max_retries:
588
+ # 重新获取凭证并重试
589
+ log.info(f"[NON-STREAM] 重试请求 (attempt {attempt + 2}/{max_retries + 1})...")
590
+
591
+ switched, next_cred_task = await _switch_credential_for_retry(
592
+ next_cred_task=next_cred_task,
593
+ retry_interval=retry_interval,
594
+ refresh_credential_fast=refresh_credential_fast,
595
+ apply_cred_result=apply_cred_result,
596
+ log_prefix="[NON-STREAM]",
597
+ )
598
+ if not switched:
599
+ log.error("[NON-STREAM] 重试时无可用凭证或刷新失败")
600
+ return Response(
601
+ content=json.dumps({"error": "当前无可用凭证"}),
602
+ status_code=500,
603
+ media_type="application/json"
604
+ )
605
+ continue # 重试
606
+ else:
607
+ # 不重试,直接返回原始错误
608
+ log.error(f"[NON-STREAM] 达到最大重试次数或不应重试,返回原始错误")
609
+ return last_error_response
610
+ elif status_code == 404 and "preview" in model_name.lower():
611
+ # 特殊处理:preview模型返回404,说明该凭证不支持preview模型
612
+ log.warning(f"[NON-STREAM] Preview模型404错误,凭证不支持preview: {current_file}")
613
+
614
+ # 将该凭证的preview状态设置为False
615
+ try:
616
+ await credential_manager.update_credential_state(
617
+ current_file, {"preview": False}, mode="geminicli"
618
+ )
619
+ log.info(f"[NON-STREAM] 已将凭证 {current_file} 的preview状态设置为False")
620
+ except Exception as e:
621
+ log.error(f"[NON-STREAM] 更新凭证preview状态失败: {e}")
622
+
623
+ # 记录404错误
624
+ await record_api_call_error(
625
+ credential_manager, current_file, status_code,
626
+ None, mode="geminicli", model_name=model_name,
627
+ error_message=error_text
628
+ )
629
+
630
+ # 预热下一个凭证(会自动跳过preview=False的凭证)
631
+ if next_cred_task is None and attempt < max_retries:
632
+ next_cred_task = asyncio.create_task(
633
+ credential_manager.get_valid_credential(
634
+ mode="geminicli", model_name=model_name
635
+ )
636
+ )
637
+
638
+ # 触发重试
639
+ if attempt < max_retries:
640
+ log.info(f"[NON-STREAM] 重试请求 (attempt {attempt + 2}/{max_retries + 1})...")
641
+
642
+ switched, next_cred_task = await _switch_credential_for_retry(
643
+ next_cred_task=next_cred_task,
644
+ retry_interval=retry_interval,
645
+ refresh_credential_fast=refresh_credential_fast,
646
+ apply_cred_result=apply_cred_result,
647
+ log_prefix="[NON-STREAM]",
648
+ )
649
+ if not switched:
650
+ log.error("[NON-STREAM] 重试时无可用凭证或刷新失败")
651
+ return Response(
652
+ content=json.dumps({"error": "当前无可用凭证"}),
653
+ status_code=500,
654
+ media_type="application/json"
655
+ )
656
+ continue # 重试
657
+ else:
658
+ log.error(f"[NON-STREAM] 达到最大重试次数,返回404错误")
659
+ return last_error_response
660
+ else:
661
+ # 错误码不在重试范围内,直接返回
662
+ log.error(f"[NON-STREAM] 非流式请求失败,非重试错误码 (status={status_code}), 凭证: {current_file}, 响应: {error_text[:500] if error_text else '无'}")
663
+ await record_api_call_error(
664
+ credential_manager, current_file, status_code,
665
+ None, mode="geminicli", model_name=model_name,
666
+ error_message=error_text
667
+ )
668
+ return last_error_response
669
+
670
+ except Exception as e:
671
+ log.error(f"非流式请求异常: {e}, 凭证: {current_file}")
672
+ if attempt < max_retries:
673
+ log.info(f"[NON-STREAM] 异常后重试 (attempt {attempt + 2}/{max_retries + 1})...")
674
+ await asyncio.sleep(retry_interval)
675
+ continue
676
+ else:
677
+ # 所有重试都失败,返回最后一次的错误(如果有)或500错误
678
+ log.error(f"[NON-STREAM] 所有重试均失败,最后异常: {e}")
679
+ if last_error_response:
680
+ return last_error_response
681
+ else:
682
+ return Response(
683
+ content=json.dumps({"error": f"请求异常: {str(e)}"}),
684
+ status_code=500,
685
+ media_type="application/json"
686
+ )
687
+
688
+ # 所有重试都失败,返回最后一次的原始错误
689
+ log.error("[NON-STREAM] 所有重试均失败")
690
+ return last_error_response
691
+
692
+
693
+ # ==================== 测试代码 ====================
694
+
695
+ if __name__ == "__main__":
696
+ """
697
+ 测试代码:演示API返回的流式和非流式数据格式
698
+ 运行方式: python src/api/geminicli.py
699
+ """
700
+ print("=" * 80)
701
+ print("GeminiCli API 测试")
702
+ print("=" * 80)
703
+
704
+ # 测试请求体
705
+ test_body = {
706
+ "model": "gemini-2.5-flash",
707
+ "request": {
708
+ "contents": [
709
+ {
710
+ "role": "user",
711
+ "parts": [{"text": "Hello, tell me a joke in one sentence."}]
712
+ }
713
+ ]
714
+ }
715
+ }
716
+
717
+ async def test_stream_request():
718
+ """测试流式请求"""
719
+ print("\n" + "=" * 80)
720
+ print("【测试1】流式请求 (stream_request with native=False)")
721
+ print("=" * 80)
722
+ print(f"请求体: {json.dumps(test_body, indent=2, ensure_ascii=False)}\n")
723
+
724
+ print("流式响应数据 (每个chunk):")
725
+ print("-" * 80)
726
+
727
+ chunk_count = 0
728
+ async for chunk in stream_request(body=test_body, native=False):
729
+ chunk_count += 1
730
+ if isinstance(chunk, Response):
731
+ # 错误响应
732
+ print(f"\n❌ 错误响应:")
733
+ print(f" 状态码: {chunk.status_code}")
734
+ print(f" Content-Type: {chunk.headers.get('content-type', 'N/A')}")
735
+ try:
736
+ content = chunk.body.decode('utf-8') if isinstance(chunk.body, bytes) else str(chunk.body)
737
+ print(f" 内容: {content}")
738
+ except Exception as e:
739
+ print(f" 内容解析失败: {e}")
740
+ else:
741
+ # 正常的流式数据块 (str类型)
742
+ print(f"\nChunk #{chunk_count}:")
743
+ print(f" 类型: {type(chunk).__name__}")
744
+ print(f" 长度: {len(chunk) if hasattr(chunk, '__len__') else 'N/A'}")
745
+ print(f" 内容预览: {repr(chunk[:200] if len(chunk) > 200 else chunk)}")
746
+
747
+ # 如果是SSE格式,尝试解析
748
+ if isinstance(chunk, str) and chunk.startswith("data: "):
749
+ try:
750
+ data_line = chunk.strip()
751
+ if data_line.startswith("data: "):
752
+ json_str = data_line[6:] # 去掉 "data: " 前缀
753
+ json_data = json.loads(json_str)
754
+ print(f" 解析后的JSON: {json.dumps(json_data, indent=4, ensure_ascii=False)}")
755
+ except Exception as e:
756
+ print(f" SSE解析尝试失败: {e}")
757
+
758
+ print(f"\n总共收到 {chunk_count} 个chunk")
759
+
760
+ async def test_non_stream_request():
761
+ """测试非流式请求"""
762
+ print("\n" + "=" * 80)
763
+ print("【测试2】非流式请求 (non_stream_request)")
764
+ print("=" * 80)
765
+ print(f"请求体: {json.dumps(test_body, indent=2, ensure_ascii=False)}\n")
766
+
767
+ response = await non_stream_request(body=test_body)
768
+
769
+ print("非流式响应数据:")
770
+ print("-" * 80)
771
+ print(f"状态码: {response.status_code}")
772
+ print(f"Content-Type: {response.headers.get('content-type', 'N/A')}")
773
+ print(f"\n响应头: {dict(response.headers)}\n")
774
+
775
+ try:
776
+ content = response.body.decode('utf-8') if isinstance(response.body, bytes) else str(response.body)
777
+ print(f"响应内容 (原始):\n{content}\n")
778
+
779
+ # 尝试解析JSON
780
+ try:
781
+ json_data = json.loads(content)
782
+ print(f"响应内容 (格式化JSON):")
783
+ print(json.dumps(json_data, indent=2, ensure_ascii=False))
784
+ except json.JSONDecodeError:
785
+ print("(非JSON格式)")
786
+ except Exception as e:
787
+ print(f"内容解析失败: {e}")
788
+
789
+ async def main():
790
+ """主测试函数"""
791
+ try:
792
+ # 测试流式请求
793
+ await test_stream_request()
794
+
795
+ # 测试非流式请求
796
+ await test_non_stream_request()
797
+
798
+ print("\n" + "=" * 80)
799
+ print("测试完成")
800
+ print("=" * 80)
801
+
802
+ except Exception as e:
803
+ print(f"\n❌ 测试过程中出现异常: {e}")
804
+ import traceback
805
+ traceback.print_exc()
806
+
807
+ # 运行测试
808
+ asyncio.run(main())
src/api/utils.py ADDED
@@ -0,0 +1,506 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Base API Client - 共用的 API 客户端基础功能
3
+ 提供错误处理、自动封禁、重试逻辑等共同功能
4
+ """
5
+
6
+ import asyncio
7
+ import json
8
+ from datetime import datetime, timezone
9
+ from typing import Any, Dict, Optional
10
+
11
+ from fastapi import Response
12
+
13
+ from config import (
14
+ get_auto_ban_enabled,
15
+ get_auto_ban_error_codes,
16
+ get_retry_429_enabled,
17
+ get_retry_429_interval,
18
+ get_retry_429_max_retries,
19
+ )
20
+ from log import log
21
+ from src.credential_manager import CredentialManager
22
+
23
+
24
+ # ==================== 错误检查与处理 ====================
25
+
26
+ async def check_should_auto_ban(status_code: int) -> bool:
27
+ """
28
+ 检查是否应该触发自动封禁
29
+
30
+ Args:
31
+ status_code: HTTP状态码
32
+
33
+ Returns:
34
+ bool: 是否应该触发自动封禁
35
+ """
36
+ return (
37
+ await get_auto_ban_enabled()
38
+ and status_code in await get_auto_ban_error_codes()
39
+ )
40
+
41
+
42
+ async def handle_auto_ban(
43
+ credential_manager: CredentialManager,
44
+ status_code: int,
45
+ credential_name: str,
46
+ mode: str = "geminicli"
47
+ ) -> None:
48
+ """
49
+ 处理自动封禁:直接禁用凭证
50
+
51
+ Args:
52
+ credential_manager: 凭证管理器实例
53
+ status_code: HTTP状态码
54
+ credential_name: 凭证名称
55
+ mode: 模式(geminicli 或 antigravity)
56
+ """
57
+ if credential_manager and credential_name:
58
+ log.warning(
59
+ f"[{mode.upper()} AUTO_BAN] Status {status_code} triggers auto-ban for credential: {credential_name}"
60
+ )
61
+ await credential_manager.set_cred_disabled(
62
+ credential_name, True, mode=mode
63
+ )
64
+
65
+
66
+ async def handle_error_with_retry(
67
+ credential_manager: CredentialManager,
68
+ status_code: int,
69
+ credential_name: str,
70
+ retry_enabled: bool,
71
+ attempt: int,
72
+ max_retries: int,
73
+ retry_interval: float,
74
+ mode: str = "geminicli"
75
+ ) -> bool:
76
+ """
77
+ 统一处理错误和重试逻辑
78
+
79
+ 仅在以下情况下进行自动重试:
80
+ 1. 429错误(速率限制)
81
+ 2. 503错误(服务不可用)
82
+ 3. 500错误(服务临时不可用)
83
+ 4. 导致凭证封禁的错误(AUTO_BAN_ERROR_CODES配置)
84
+
85
+ Args:
86
+ credential_manager: 凭证管理器实例
87
+ status_code: HTTP状态码
88
+ credential_name: 凭证名称
89
+ retry_enabled: 是否启用重试
90
+ attempt: 当前重试次数
91
+ max_retries: 最大重试次数
92
+ retry_interval: 重试间隔
93
+ mode: 模式(geminicli 或 antigravity)
94
+
95
+ Returns:
96
+ bool: True表示需要继续重试,False表示不需要重试
97
+ """
98
+ # 优先检查自动封禁
99
+ should_auto_ban = await check_should_auto_ban(status_code)
100
+
101
+ if should_auto_ban:
102
+ # 触发自动封禁
103
+ await handle_auto_ban(credential_manager, status_code, credential_name, mode)
104
+
105
+ # 自动封禁后,仍然尝试重试(会在下次循环中自动获取新凭证)
106
+ if retry_enabled and attempt < max_retries:
107
+ log.info(
108
+ f"[{mode.upper()} RETRY] Retrying with next credential after auto-ban "
109
+ f"(status {status_code}, attempt {attempt + 1}/{max_retries})"
110
+ )
111
+ await asyncio.sleep(retry_interval)
112
+ return True
113
+ return False
114
+
115
+ # 如果不触发自动封禁,仅对429、503和500错误进行重试
116
+ if status_code in (429, 500, 503) and retry_enabled and attempt < max_retries:
117
+ log.info(
118
+ f"[{mode.upper()} RETRY] {status_code} error encountered, retrying "
119
+ f"(attempt {attempt + 1}/{max_retries})"
120
+ )
121
+ await asyncio.sleep(retry_interval)
122
+ return True
123
+
124
+ # 其他错误不进行重试
125
+ return False
126
+
127
+
128
+ # ==================== 重试配置获取 ====================
129
+
130
+ async def get_retry_config() -> Dict[str, Any]:
131
+ """
132
+ 获取重试配置
133
+
134
+ Returns:
135
+ 包含重试配置的字典
136
+ """
137
+ return {
138
+ "retry_enabled": await get_retry_429_enabled(),
139
+ "max_retries": await get_retry_429_max_retries(),
140
+ "retry_interval": await get_retry_429_interval(),
141
+ }
142
+
143
+
144
+ # ==================== API调用结果记录 ====================
145
+
146
+ async def record_api_call_success(
147
+ credential_manager: CredentialManager,
148
+ credential_name: str,
149
+ mode: str = "geminicli",
150
+ model_name: Optional[str] = None
151
+ ) -> None:
152
+ """
153
+ 记录API调用成功
154
+
155
+ Args:
156
+ credential_manager: 凭证管理器实例
157
+ credential_name: 凭证名称
158
+ mode: 模式(geminicli 或 antigravity)
159
+ model_name: 模型名称(用于模型级CD)
160
+ """
161
+ if credential_manager and credential_name:
162
+ await credential_manager.record_api_call_result(
163
+ credential_name, True, mode=mode, model_name=model_name
164
+ )
165
+
166
+
167
+ async def record_api_call_error(
168
+ credential_manager: CredentialManager,
169
+ credential_name: str,
170
+ status_code: int,
171
+ cooldown_until: Optional[float] = None,
172
+ mode: str = "geminicli",
173
+ model_name: Optional[str] = None,
174
+ error_message: Optional[str] = None
175
+ ) -> None:
176
+ """
177
+ 记录API调用错误
178
+
179
+ Args:
180
+ credential_manager: 凭证管理器实例
181
+ credential_name: 凭证名称
182
+ status_code: HTTP状态码
183
+ cooldown_until: 冷却截止时间(Unix时间戳)
184
+ mode: 模式(geminicli 或 antigravity)
185
+ model_name: 模型名称(用于模型级CD)
186
+ error_message: 错误信息(可选)
187
+ """
188
+ if credential_manager and credential_name:
189
+ await credential_manager.record_api_call_result(
190
+ credential_name,
191
+ False,
192
+ status_code,
193
+ cooldown_until=cooldown_until,
194
+ mode=mode,
195
+ model_name=model_name,
196
+ error_message=error_message
197
+ )
198
+
199
+
200
+ # ==================== 429错误处理 ====================
201
+
202
+ async def parse_and_log_cooldown(
203
+ error_text: str,
204
+ mode: str = "geminicli"
205
+ ) -> Optional[float]:
206
+ """
207
+ 解析并记录冷却时间
208
+
209
+ Args:
210
+ error_text: 错误响应文本
211
+ mode: 模式(geminicli 或 antigravity)
212
+
213
+ Returns:
214
+ 冷却截止时间(Unix时间戳),如果解析失败则返回None
215
+ """
216
+ try:
217
+ error_data = json.loads(error_text)
218
+ cooldown_until = parse_quota_reset_timestamp(error_data)
219
+ if cooldown_until:
220
+ log.info(
221
+ f"[{mode.upper()}] 检测到quota冷却时间: "
222
+ f"{datetime.fromtimestamp(cooldown_until, timezone.utc).isoformat()}"
223
+ )
224
+ return cooldown_until
225
+ except Exception as parse_err:
226
+ log.debug(f"[{mode.upper()}] Failed to parse cooldown time: {parse_err}")
227
+ return None
228
+
229
+
230
+ # ==================== 流式响应收集 ====================
231
+
232
+ async def collect_streaming_response(stream_generator) -> Response:
233
+ """
234
+ 将Gemini流式响应收集为一条完整的非流式响应
235
+
236
+ Args:
237
+ stream_generator: 流式响应生成器,产生 "data: {json}" 格式的行或Response对象
238
+
239
+ Returns:
240
+ Response: 合并后的完整响应对象
241
+
242
+ Example:
243
+ >>> async for line in stream_generator:
244
+ ... # line format: "data: {...}" or Response object
245
+ >>> response = await collect_streaming_response(stream_generator)
246
+ """
247
+ # 初始化响应结构
248
+ merged_response = {
249
+ "response": {
250
+ "candidates": [{
251
+ "content": {
252
+ "parts": [],
253
+ "role": "model"
254
+ },
255
+ "finishReason": None,
256
+ "safetyRatings": [],
257
+ "citationMetadata": None
258
+ }],
259
+ "usageMetadata": {
260
+ "promptTokenCount": 0,
261
+ "candidatesTokenCount": 0,
262
+ "totalTokenCount": 0
263
+ }
264
+ }
265
+ }
266
+
267
+ collected_text = [] # 用于收集文本内容
268
+ collected_thought_text = [] # 用于收集思维链内容
269
+ collected_other_parts = [] # 用于收集其他类型的parts(图片、文件、工具调用等)
270
+ collected_tool_parts_count = 0 # 记录工具调用相关part数量
271
+ has_data = False
272
+ line_count = 0
273
+
274
+ log.debug("[STREAM COLLECTOR] Starting to collect streaming response")
275
+
276
+ try:
277
+ async for line in stream_generator:
278
+ line_count += 1
279
+
280
+ # 如果收到的是Response对象(错误),直接返回
281
+ if isinstance(line, Response):
282
+ log.debug(f"[STREAM COLLECTOR] 收到错误Response,状态码: {line.status_code}")
283
+ return line
284
+
285
+ # 处理 bytes 类型
286
+ if isinstance(line, bytes):
287
+ line_str = line.decode('utf-8', errors='ignore')
288
+ log.debug(f"[STREAM COLLECTOR] Processing bytes line {line_count}: {line_str[:200] if line_str else 'empty'}")
289
+ elif isinstance(line, str):
290
+ line_str = line
291
+ log.debug(f"[STREAM COLLECTOR] Processing line {line_count}: {line_str[:200] if line_str else 'empty'}")
292
+ else:
293
+ log.debug(f"[STREAM COLLECTOR] Skipping non-string/bytes line: {type(line)}")
294
+ continue
295
+
296
+ # 解析流式数据行
297
+ if not line_str.startswith("data: "):
298
+ log.debug(f"[STREAM COLLECTOR] Skipping line without 'data: ' prefix: {line_str[:100]}")
299
+ continue
300
+
301
+ raw = line_str[6:].strip()
302
+ if raw == "[DONE]":
303
+ log.debug("[STREAM COLLECTOR] Received [DONE] marker")
304
+ break
305
+
306
+ try:
307
+ log.debug(f"[STREAM COLLECTOR] Parsing JSON: {raw[:200]}")
308
+ chunk = json.loads(raw)
309
+ has_data = True
310
+ log.debug(f"[STREAM COLLECTOR] Chunk keys: {chunk.keys() if isinstance(chunk, dict) else type(chunk)}")
311
+
312
+ # 提取响应对象
313
+ response_obj = chunk.get("response", {})
314
+ if not response_obj:
315
+ log.debug("[STREAM COLLECTOR] No 'response' key in chunk, trying direct access")
316
+ response_obj = chunk # 尝试直接使用chunk
317
+
318
+ candidates = response_obj.get("candidates", [])
319
+ log.debug(f"[STREAM COLLECTOR] Found {len(candidates)} candidates")
320
+ if not candidates:
321
+ log.debug(f"[STREAM COLLECTOR] No candidates in chunk, chunk structure: {list(chunk.keys()) if isinstance(chunk, dict) else type(chunk)}")
322
+ continue
323
+
324
+ candidate = candidates[0]
325
+
326
+ # 收集文本内容
327
+ content = candidate.get("content", {})
328
+ parts = content.get("parts", [])
329
+ log.debug(f"[STREAM COLLECTOR] Processing {len(parts)} parts from candidate")
330
+
331
+ for part in parts:
332
+ if not isinstance(part, dict):
333
+ continue
334
+
335
+ # 优先保留工具调用相关 part(functionCall / functionResponse)
336
+ # 避免在 stream2nostream 模式下工具调用丢失
337
+ if "functionCall" in part or "functionResponse" in part or "function_call" in part:
338
+ collected_other_parts.append(part)
339
+ collected_tool_parts_count += 1
340
+ log.debug(f"[STREAM COLLECTOR] Collected tool part: {list(part.keys())}")
341
+ continue
342
+
343
+ # 处理文本内容
344
+ text = part.get("text", "")
345
+ if text:
346
+ # 区分普通文本和思维链
347
+ if part.get("thought", False):
348
+ collected_thought_text.append(text)
349
+ log.debug(f"[STREAM COLLECTOR] Collected thought text: {text[:100]}")
350
+ else:
351
+ collected_text.append(text)
352
+ log.debug(f"[STREAM COLLECTOR] Collected regular text: {text[:100]}")
353
+ # 处理非文本内容(图片、文件等)
354
+ elif "inlineData" in part or "fileData" in part or "executableCode" in part or "codeExecutionResult" in part:
355
+ collected_other_parts.append(part)
356
+ log.debug(f"[STREAM COLLECTOR] Collected non-text part: {list(part.keys())}")
357
+
358
+ # 收集其他信息(使用最后一个块的值)
359
+ if candidate.get("finishReason"):
360
+ merged_response["response"]["candidates"][0]["finishReason"] = candidate["finishReason"]
361
+
362
+ if candidate.get("safetyRatings"):
363
+ merged_response["response"]["candidates"][0]["safetyRatings"] = candidate["safetyRatings"]
364
+
365
+ if candidate.get("citationMetadata"):
366
+ merged_response["response"]["candidates"][0]["citationMetadata"] = candidate["citationMetadata"]
367
+
368
+ # 更新使用元数据
369
+ usage = response_obj.get("usageMetadata", {})
370
+ if usage:
371
+ merged_response["response"]["usageMetadata"].update(usage)
372
+
373
+ except json.JSONDecodeError as e:
374
+ log.debug(f"[STREAM COLLECTOR] Failed to parse JSON chunk: {e}")
375
+ continue
376
+ except Exception as e:
377
+ log.debug(f"[STREAM COLLECTOR] Error processing chunk: {e}")
378
+ continue
379
+
380
+ except Exception as e:
381
+ log.error(f"[STREAM COLLECTOR] Error collecting stream after {line_count} lines: {e}")
382
+ return Response(
383
+ content=json.dumps({"error": f"收集流式响应失败: {str(e)}"}),
384
+ status_code=500,
385
+ media_type="application/json"
386
+ )
387
+
388
+ log.debug(f"[STREAM COLLECTOR] Finished iteration, has_data={has_data}, line_count={line_count}")
389
+
390
+ # 如果没有收集到任何数据,返回错误
391
+ if not has_data:
392
+ log.error(f"[STREAM COLLECTOR] No data collected from stream after {line_count} lines")
393
+ return Response(
394
+ content=json.dumps({"error": "No data collected from stream"}),
395
+ status_code=500,
396
+ media_type="application/json"
397
+ )
398
+
399
+ # 组装最终的parts
400
+ final_parts = []
401
+
402
+ # 先添加思维链内容(如果有)
403
+ if collected_thought_text:
404
+ final_parts.append({
405
+ "text": "".join(collected_thought_text),
406
+ "thought": True
407
+ })
408
+
409
+ # 再添加普通文本内容
410
+ if collected_text:
411
+ final_parts.append({
412
+ "text": "".join(collected_text)
413
+ })
414
+
415
+ # 添加其他类型的parts(图片、文件等)
416
+ final_parts.extend(collected_other_parts)
417
+
418
+ # 如果没有任何内容,添加空文本
419
+ if not final_parts:
420
+ final_parts.append({"text": ""})
421
+
422
+ merged_response["response"]["candidates"][0]["content"]["parts"] = final_parts
423
+
424
+ log.info(
425
+ f"[STREAM COLLECTOR] Collected {len(collected_text)} text chunks, "
426
+ f"{len(collected_thought_text)} thought chunks, {len(collected_other_parts)} other parts "
427
+ f"(tool parts: {collected_tool_parts_count})"
428
+ )
429
+
430
+ # 去掉嵌套的 "response" 包装(Antigravity格式 -> 标准Gemini格式)
431
+ if "response" in merged_response and "candidates" not in merged_response:
432
+ log.debug(f"[STREAM COLLECTOR] 展开response包装")
433
+ merged_response = merged_response["response"]
434
+
435
+ # 返回纯JSON格式
436
+ return Response(
437
+ content=json.dumps(merged_response, ensure_ascii=False).encode('utf-8'),
438
+ status_code=200,
439
+ headers={},
440
+ media_type="application/json"
441
+ )
442
+
443
+
444
+ RESOURCE_EXHAUSTED_COOLDOWN_HOURS = 4 # RESOURCE_EXHAUSTED 错误的默认冷却时间(小时)
445
+
446
+
447
+ def parse_quota_reset_timestamp(error_response: dict) -> Optional[float]:
448
+ """
449
+ 从Google API错误响应中提取quota重置时间戳
450
+
451
+ Args:
452
+ error_response: Google API返回的错误响应字典
453
+
454
+ Returns:
455
+ Unix时间戳(秒),如果无法解析则返回None
456
+
457
+ 示例错误响应:
458
+ {
459
+ "error": {
460
+ "code": 429,
461
+ "message": "You have exhausted your capacity...",
462
+ "status": "RESOURCE_EXHAUSTED",
463
+ "details": [
464
+ {
465
+ "@type": "type.googleapis.com/google.rpc.ErrorInfo",
466
+ "reason": "QUOTA_EXHAUSTED",
467
+ "metadata": {
468
+ "quotaResetTimeStamp": "2025-11-30T14:57:24Z",
469
+ "quotaResetDelay": "13h19m1.20964964s"
470
+ }
471
+ }
472
+ ]
473
+ }
474
+ }
475
+ """
476
+ try:
477
+ error_obj = error_response.get("error", {})
478
+ details = error_obj.get("details", [])
479
+
480
+ for detail in details:
481
+ if detail.get("@type") == "type.googleapis.com/google.rpc.ErrorInfo":
482
+ reset_timestamp_str = detail.get("metadata", {}).get("quotaResetTimeStamp")
483
+
484
+ if reset_timestamp_str:
485
+ if reset_timestamp_str.endswith("Z"):
486
+ reset_timestamp_str = reset_timestamp_str.replace("Z", "+00:00")
487
+
488
+ reset_dt = datetime.fromisoformat(reset_timestamp_str)
489
+ if reset_dt.tzinfo is None:
490
+ reset_dt = reset_dt.replace(tzinfo=timezone.utc)
491
+
492
+ return reset_dt.astimezone(timezone.utc).timestamp()
493
+
494
+ # 如果是 RESOURCE_EXHAUSTED 错误且消息完全匹配,设置默认4小时冷却时间
495
+ if (
496
+ error_obj.get("status") == "RESOURCE_EXHAUSTED"
497
+ and error_obj.get("message") == "Resource has been exhausted (e.g. check quota)."
498
+ ):
499
+ import time
500
+ cooldown_until = time.time() + RESOURCE_EXHAUSTED_COOLDOWN_HOURS * 3600
501
+ return cooldown_until
502
+
503
+ return None
504
+
505
+ except Exception:
506
+ return None
src/auth.py ADDED
@@ -0,0 +1,1089 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 认证API模块
3
+ """
4
+
5
+ import asyncio
6
+ import socket
7
+ import threading
8
+ import time
9
+ import uuid
10
+ from datetime import timezone
11
+ from http.server import BaseHTTPRequestHandler, HTTPServer
12
+ from typing import Any, Dict, Optional
13
+ from urllib.parse import parse_qs, urlparse
14
+
15
+ from config import get_config_value, get_antigravity_api_url, get_code_assist_endpoint
16
+ from log import log
17
+
18
+ from .google_oauth_api import (
19
+ Credentials,
20
+ Flow,
21
+ enable_required_apis,
22
+ fetch_project_id_and_tier,
23
+ get_user_projects,
24
+ select_default_project,
25
+ )
26
+ from .storage_adapter import get_storage_adapter
27
+ from .utils import (
28
+ ANTIGRAVITY_CLIENT_ID,
29
+ ANTIGRAVITY_CLIENT_SECRET,
30
+ ANTIGRAVITY_SCOPES,
31
+ ANTIGRAVITY_USER_AGENT,
32
+ CALLBACK_HOST,
33
+ CLIENT_ID,
34
+ CLIENT_SECRET,
35
+ SCOPES,
36
+ GEMINICLI_USER_AGENT,
37
+ TOKEN_URL,
38
+ )
39
+
40
+
41
+ async def get_callback_port():
42
+ """获取OAuth回调端口"""
43
+ return int(await get_config_value("oauth_callback_port", "11451", "OAUTH_CALLBACK_PORT"))
44
+
45
+
46
+ def _prepare_credentials_data(credentials: Credentials, project_id: str, mode: str = "geminicli", subscription_tier: str = None) -> Dict[str, Any]:
47
+ """准备凭证数据字典(统一函数)"""
48
+ if mode == "antigravity":
49
+ creds_data = {
50
+ "client_id": ANTIGRAVITY_CLIENT_ID,
51
+ "client_secret": ANTIGRAVITY_CLIENT_SECRET,
52
+ "token": credentials.access_token,
53
+ "refresh_token": credentials.refresh_token,
54
+ "scopes": ANTIGRAVITY_SCOPES,
55
+ "token_uri": TOKEN_URL,
56
+ "project_id": project_id,
57
+ }
58
+ else:
59
+ creds_data = {
60
+ "client_id": CLIENT_ID,
61
+ "client_secret": CLIENT_SECRET,
62
+ "token": credentials.access_token,
63
+ "refresh_token": credentials.refresh_token,
64
+ "scopes": SCOPES,
65
+ "token_uri": TOKEN_URL,
66
+ "project_id": project_id,
67
+ }
68
+
69
+ if credentials.expires_at:
70
+ if credentials.expires_at.tzinfo is None:
71
+ expiry_utc = credentials.expires_at.replace(tzinfo=timezone.utc)
72
+ else:
73
+ expiry_utc = credentials.expires_at
74
+ creds_data["expiry"] = expiry_utc.isoformat()
75
+
76
+ return creds_data
77
+
78
+
79
+ def _cleanup_auth_flow_server(state: str):
80
+ """清理认证流程的服务器资源"""
81
+ if state in auth_flows:
82
+ flow_data_to_clean = auth_flows[state]
83
+ try:
84
+ if flow_data_to_clean.get("server"):
85
+ server = flow_data_to_clean["server"]
86
+ port = flow_data_to_clean.get("callback_port")
87
+ async_shutdown_server(server, port)
88
+ except Exception as e:
89
+ log.debug(f"关闭服务器时出错: {e}")
90
+ del auth_flows[state]
91
+
92
+
93
+ class _OAuthLibPatcher:
94
+ """oauthlib参数验证补丁的上下文管理器"""
95
+ def __init__(self):
96
+ import oauthlib.oauth2.rfc6749.parameters
97
+ self.module = oauthlib.oauth2.rfc6749.parameters
98
+ self.original_validate = None
99
+
100
+ def __enter__(self):
101
+ self.original_validate = self.module.validate_token_parameters
102
+
103
+ def patched_validate(params):
104
+ try:
105
+ return self.original_validate(params)
106
+ except Warning:
107
+ pass
108
+
109
+ self.module.validate_token_parameters = patched_validate
110
+ return self
111
+
112
+ def __exit__(self, exc_type, exc_val, exc_tb):
113
+ if self.original_validate:
114
+ self.module.validate_token_parameters = self.original_validate
115
+
116
+
117
+ # 全局状态管理 - 严格限制大小
118
+ auth_flows = {} # 存储进行中的认证流程
119
+ MAX_AUTH_FLOWS = 20 # 严格限制最大认证流程数
120
+ DEFAULT_PROJECT_ID = "gemini-pro-1751713012-07fc4dfd"
121
+
122
+
123
+ def cleanup_auth_flows_for_memory():
124
+ """清理认证流程以释放内存"""
125
+ global auth_flows
126
+ cleanup_expired_flows()
127
+ # 如果还是太多,强制清理一些旧的流程
128
+ if len(auth_flows) > 10:
129
+ # 按创建时间排序,保留最新的10个
130
+ sorted_flows = sorted(
131
+ auth_flows.items(), key=lambda x: x[1].get("created_at", 0), reverse=True
132
+ )
133
+ new_auth_flows = dict(sorted_flows[:10])
134
+
135
+ # 清理被移除的流程
136
+ for state, flow_data in auth_flows.items():
137
+ if state not in new_auth_flows:
138
+ try:
139
+ if flow_data.get("server"):
140
+ server = flow_data["server"]
141
+ port = flow_data.get("callback_port")
142
+ async_shutdown_server(server, port)
143
+ except Exception:
144
+ pass
145
+ flow_data.clear()
146
+
147
+ auth_flows = new_auth_flows
148
+ log.info(f"强制清理认证流程,保留 {len(auth_flows)} 个最新流程")
149
+
150
+ return len(auth_flows)
151
+
152
+
153
+ async def find_available_port(start_port: int = None) -> int:
154
+ """动态查找可用端口"""
155
+ if start_port is None:
156
+ start_port = await get_callback_port()
157
+
158
+ # ���先尝试默认端口
159
+ for port in range(start_port, start_port + 100): # 尝试100个端口
160
+ try:
161
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
162
+ s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
163
+ s.bind(("0.0.0.0", port))
164
+ log.info(f"找到可用端口: {port}")
165
+ return port
166
+ except OSError:
167
+ continue
168
+
169
+ # 如果都不可用,让系统自动分配端口
170
+ try:
171
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
172
+ s.bind(("0.0.0.0", 0))
173
+ port = s.getsockname()[1]
174
+ log.info(f"系统分配可用端口: {port}")
175
+ return port
176
+ except OSError as e:
177
+ log.error(f"无法找到可用端口: {e}")
178
+ raise RuntimeError("无法找到可用端口")
179
+
180
+
181
+ def create_callback_server(port: int) -> HTTPServer:
182
+ """创建指定端口的回调服务器,优化快速关闭"""
183
+ try:
184
+ # 服务器监听0.0.0.0
185
+ server = HTTPServer(("0.0.0.0", port), AuthCallbackHandler)
186
+
187
+ # 设置socket选项以支持快速关闭
188
+ server.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
189
+ # 设置较短的超时时间
190
+ server.timeout = 1.0
191
+
192
+ log.info(f"创建OAuth回调服务器,监听端口: {port}")
193
+ return server
194
+ except OSError as e:
195
+ log.error(f"创建端口{port}的服务器失败: {e}")
196
+ raise
197
+
198
+
199
+ class AuthCallbackHandler(BaseHTTPRequestHandler):
200
+ """OAuth回调处理器"""
201
+
202
+ def do_GET(self):
203
+ query_components = parse_qs(urlparse(self.path).query)
204
+ code = query_components.get("code", [None])[0]
205
+ state = query_components.get("state", [None])[0]
206
+
207
+ log.info(f"收到OAuth回调: code={'已获取' if code else '未获取'}, state={state}")
208
+
209
+ if code and state and state in auth_flows:
210
+ # 更新流程状态
211
+ auth_flows[state]["code"] = code
212
+ auth_flows[state]["completed"] = True
213
+
214
+ log.info(f"OAuth回调成功处理: state={state}")
215
+
216
+ self.send_response(200)
217
+ self.send_header("Content-type", "text/html")
218
+ self.end_headers()
219
+ # 成功页面
220
+ self.wfile.write(
221
+ b"<h1>OAuth authentication successful!</h1><p>You can close this window. Please return to the original page and click 'Get Credentials' button.</p>"
222
+ )
223
+ else:
224
+ self.send_response(400)
225
+ self.send_header("Content-type", "text/html")
226
+ self.end_headers()
227
+ self.wfile.write(b"<h1>Authentication failed.</h1><p>Please try again.</p>")
228
+
229
+ def log_message(self, format, *args):
230
+ # 减少日志噪音
231
+ pass
232
+
233
+
234
+ async def create_auth_url(
235
+ project_id: Optional[str] = None, user_session: str = None, mode: str = "geminicli"
236
+ ) -> Dict[str, Any]:
237
+ """创建认证URL,支持动态端口分配"""
238
+ try:
239
+ # 动态分配端口
240
+ callback_port = await find_available_port()
241
+ callback_url = f"http://{CALLBACK_HOST}:{callback_port}"
242
+
243
+ # 立即启动回调服务器
244
+ try:
245
+ callback_server = create_callback_server(callback_port)
246
+ # 在后台线程中运行服务器
247
+ server_thread = threading.Thread(
248
+ target=callback_server.serve_forever,
249
+ daemon=True,
250
+ name=f"OAuth-Server-{callback_port}",
251
+ )
252
+ server_thread.start()
253
+ log.info(f"OAuth回调服务器已启动,端口: {callback_port}")
254
+ except Exception as e:
255
+ log.error(f"启动回调服务器失败: {e}")
256
+ return {
257
+ "success": False,
258
+ "error": f"无法启动OAuth回调服务器,端口{callback_port}: {str(e)}",
259
+ }
260
+
261
+ # 创建OAuth流程
262
+ # 根据模式选择配置
263
+ if mode == "antigravity":
264
+ client_id = ANTIGRAVITY_CLIENT_ID
265
+ client_secret = ANTIGRAVITY_CLIENT_SECRET
266
+ scopes = ANTIGRAVITY_SCOPES
267
+ else:
268
+ client_id = CLIENT_ID
269
+ client_secret = CLIENT_SECRET
270
+ scopes = SCOPES
271
+
272
+ flow = Flow(
273
+ client_id=client_id,
274
+ client_secret=client_secret,
275
+ scopes=scopes,
276
+ redirect_uri=callback_url,
277
+ )
278
+
279
+ # 生成状态标识符,包含用户会话信息
280
+ if user_session:
281
+ state = f"{user_session}_{str(uuid.uuid4())}"
282
+ else:
283
+ state = str(uuid.uuid4())
284
+
285
+ # 生成认证URL
286
+ auth_url = flow.get_auth_url(state=state)
287
+
288
+ # 严格控制认证流程数量 - 超过限制时立即清理最旧的
289
+ if len(auth_flows) >= MAX_AUTH_FLOWS:
290
+ # 清理最旧的认证流程
291
+ oldest_state = min(auth_flows.keys(), key=lambda k: auth_flows[k].get("created_at", 0))
292
+ try:
293
+ # 清理服务器资源
294
+ old_flow = auth_flows[oldest_state]
295
+ if old_flow.get("server"):
296
+ server = old_flow["server"]
297
+ port = old_flow.get("callback_port")
298
+ async_shutdown_server(server, port)
299
+ except Exception as e:
300
+ log.warning(f"Failed to cleanup old auth flow {oldest_state}: {e}")
301
+
302
+ del auth_flows[oldest_state]
303
+ log.debug(f"Removed oldest auth flow: {oldest_state}")
304
+
305
+ # 保存流程状态
306
+ auth_flows[state] = {
307
+ "flow": flow,
308
+ "project_id": project_id, # 可能为None,稍后在回调时确定
309
+ "user_session": user_session,
310
+ "callback_port": callback_port, # 存储分配的端口
311
+ "callback_url": callback_url, # 存储完整回调URL
312
+ "server": callback_server, # 存储服务器实例
313
+ "server_thread": server_thread, # 存储服务器线程
314
+ "code": None,
315
+ "completed": False,
316
+ "created_at": time.time(),
317
+ "auto_project_detection": project_id is None, # 标记是否需要自动检测项目ID
318
+ "mode": mode, # 凭证模式
319
+ }
320
+
321
+ # 清理过期的流程(30分钟)
322
+ cleanup_expired_flows()
323
+
324
+ log.info(f"OAuth流程已创建: state={state}, project_id={project_id}")
325
+ log.info(f"用户需要访问认证URL,然后OAuth会回调到 {callback_url}")
326
+ log.info(f"为此认证流程分配的端口: {callback_port}")
327
+
328
+ return {
329
+ "auth_url": auth_url,
330
+ "state": state,
331
+ "callback_port": callback_port,
332
+ "success": True,
333
+ "auto_project_detection": project_id is None,
334
+ "detected_project_id": project_id,
335
+ }
336
+
337
+ except Exception as e:
338
+ log.error(f"创建认证URL失败: {e}")
339
+ return {"success": False, "error": str(e)}
340
+
341
+
342
+ def wait_for_callback_sync(state: str, timeout: int = 300) -> Optional[str]:
343
+ """同步等待OAuth回调完成,使用对应流程的专用服务器"""
344
+ if state not in auth_flows:
345
+ log.error(f"未找到状态为 {state} 的认证流程")
346
+ return None
347
+
348
+ flow_data = auth_flows[state]
349
+ callback_port = flow_data["callback_port"]
350
+
351
+ # 服务器已经在create_auth_url时启动了,这里只需要等待
352
+ log.info(f"等待OAuth回调完成,端口: {callback_port}")
353
+
354
+ # 等待回调完成
355
+ start_time = time.time()
356
+ while time.time() - start_time < timeout:
357
+ if flow_data.get("code"):
358
+ log.info("OAuth回调成功完成")
359
+ return flow_data["code"]
360
+ time.sleep(0.5) # 每0.5秒检查一次
361
+
362
+ # 刷新flow_data引用
363
+ if state in auth_flows:
364
+ flow_data = auth_flows[state]
365
+
366
+ log.warning(f"等待OAuth回调超时 ({timeout}秒)")
367
+ return None
368
+
369
+
370
+ async def complete_auth_flow(
371
+ project_id: Optional[str] = None, user_session: str = None
372
+ ) -> Dict[str, Any]:
373
+ """完成认证流程并保存凭证,支持自动检测项目ID"""
374
+ try:
375
+ # 查找对应的认证流程
376
+ state = None
377
+ flow_data = None
378
+
379
+ # 如果指定了project_id,先尝试匹配指定的项目
380
+ if project_id:
381
+ for s, data in auth_flows.items():
382
+ if data["project_id"] == project_id:
383
+ # 如果指定了用户会话,优先匹配相同会话的流程
384
+ if user_session and data.get("user_session") == user_session:
385
+ state = s
386
+ flow_data = data
387
+ break
388
+ # 如果没有指定会话,或没找到匹配会话的流程,使用第一个匹配项目ID的
389
+ elif not state:
390
+ state = s
391
+ flow_data = data
392
+
393
+ # 如果没有指定项目ID或没找到匹配的,查找需要自动检测项目ID的流程
394
+ if not state:
395
+ for s, data in auth_flows.items():
396
+ if data.get("auto_project_detection", False):
397
+ # 如果指定了用户会话,优先匹配相同会话的流程
398
+ if user_session and data.get("user_session") == user_session:
399
+ state = s
400
+ flow_data = data
401
+ break
402
+ # 使用第一个找到的需要自动检测的流程
403
+ elif not state:
404
+ state = s
405
+ flow_data = data
406
+
407
+ if not state or not flow_data:
408
+ return {"success": False, "error": "未找到对应的认证流程,请先点击获取认证链接"}
409
+
410
+ if not project_id:
411
+ project_id = flow_data.get("project_id")
412
+ if not project_id:
413
+ project_id = DEFAULT_PROJECT_ID
414
+ log.warning(f"未获取到project_id,使用默认project_id: {project_id}")
415
+
416
+ flow = flow_data["flow"]
417
+
418
+ # 如果还没有授权码,需要等待回调
419
+ if not flow_data.get("code"):
420
+ log.info(f"等待用户完成OAuth授权 (state: {state})")
421
+ auth_code = wait_for_callback_sync(state)
422
+
423
+ if not auth_code:
424
+ return {
425
+ "success": False,
426
+ "error": "未接收到授权回调,请确保完成了浏览器中的OAuth认证",
427
+ }
428
+
429
+ # 更新流程数据
430
+ auth_flows[state]["code"] = auth_code
431
+ auth_flows[state]["completed"] = True
432
+ else:
433
+ auth_code = flow_data["code"]
434
+
435
+ # 使用认证代码获取凭证
436
+ with _OAuthLibPatcher():
437
+ try:
438
+ credentials = await flow.exchange_code(auth_code)
439
+ # credentials 已经在 exchange_code 中获得
440
+
441
+ # 如果需要自动检测项目ID且没有提供项目ID
442
+ if flow_data.get("auto_project_detection", False) and not project_id:
443
+ log.info("尝试通过API获取用户项目列表...")
444
+ log.info(f"使用的token: {credentials.access_token[:20]}...")
445
+ log.info(f"Token过期时间: {credentials.expires_at}")
446
+ user_projects = await get_user_projects(credentials)
447
+
448
+ if user_projects:
449
+ # 如果只有一个项目,自动使用
450
+ if len(user_projects) == 1:
451
+ # Google API returns projectId in camelCase
452
+ project_id = user_projects[0].get("projectId")
453
+ if project_id:
454
+ flow_data["project_id"] = project_id
455
+ log.info(f"自动选择唯一项目: {project_id}")
456
+ # 如果有多个项目,尝试选择默认项目
457
+ else:
458
+ project_id = await select_default_project(user_projects)
459
+ if project_id:
460
+ flow_data["project_id"] = project_id
461
+ log.info(f"自动选择默认项目: {project_id}")
462
+ else:
463
+ # 返回项目列表让用户选择
464
+ return {
465
+ "success": False,
466
+ "error": "请从以下项目中选择一个",
467
+ "requires_project_selection": True,
468
+ "available_projects": [
469
+ {
470
+ # Google API returns projectId in camelCase
471
+ "project_id": p.get("projectId"),
472
+ "name": p.get("displayName") or p.get("projectId"),
473
+ "projectNumber": p.get("projectNumber"),
474
+ }
475
+ for p in user_projects
476
+ ],
477
+ }
478
+ else:
479
+ # 如果无法获取项目列表,使用默认project_id
480
+ project_id = DEFAULT_PROJECT_ID
481
+ flow_data["project_id"] = project_id
482
+ log.warning(f"无法获取项目列表,使用默认project_id: {project_id}")
483
+
484
+ # 如果仍然没有项目ID,返回错误
485
+ if not project_id:
486
+ project_id = DEFAULT_PROJECT_ID
487
+ flow_data["project_id"] = project_id
488
+ log.warning(f"仍未获取到project_id,使用默认project_id: {project_id}")
489
+
490
+ # 保存凭证
491
+ saved_filename = await save_credentials(credentials, project_id)
492
+
493
+ # 准备返回的凭证数据
494
+ creds_data = _prepare_credentials_data(credentials, project_id, mode="geminicli")
495
+
496
+ # 清理使用过的流程
497
+ _cleanup_auth_flow_server(state)
498
+
499
+ log.info("OAuth认证成功,凭证已保存")
500
+ return {
501
+ "success": True,
502
+ "credentials": creds_data,
503
+ "file_path": saved_filename,
504
+ "auto_detected_project": flow_data.get("auto_project_detection", False),
505
+ }
506
+
507
+ except Exception as e:
508
+ log.error(f"获取凭证失败: {e}")
509
+ return {"success": False, "error": f"获取凭证失败: {str(e)}"}
510
+
511
+ except Exception as e:
512
+ log.error(f"完成认证流程失败: {e}")
513
+ return {"success": False, "error": str(e)}
514
+
515
+
516
+ async def asyncio_complete_auth_flow(
517
+ project_id: Optional[str] = None, user_session: str = None, mode: str = "geminicli"
518
+ ) -> Dict[str, Any]:
519
+ """异步完成认证流程,支持自动检测项目ID"""
520
+ try:
521
+ log.info(
522
+ f"asyncio_complete_auth_flow开始执行: project_id={project_id}, user_session={user_session}"
523
+ )
524
+
525
+ # 查找对应的认证流程
526
+ state = None
527
+ flow_data = None
528
+
529
+ log.debug(f"当前所有auth_flows: {list(auth_flows.keys())}")
530
+
531
+ # 如果指定了project_id,先尝试匹配指定的项目
532
+ if project_id:
533
+ log.info(f"尝试匹配指定的项目ID: {project_id}")
534
+ for s, data in auth_flows.items():
535
+ if data["project_id"] == project_id:
536
+ # 如果指定了用户会话,优先匹配相同会话的流程
537
+ if user_session and data.get("user_session") == user_session:
538
+ state = s
539
+ flow_data = data
540
+ log.info(f"找到匹配的用户会话: {s}")
541
+ break
542
+ # 如果没有指定会话,或没找到匹配会话的流程,使用第一个匹配项目ID的
543
+ elif not state:
544
+ state = s
545
+ flow_data = data
546
+ log.info(f"找到匹配的项目ID: {s}")
547
+
548
+ # 如果没有指定项目ID或没找到匹配的,查找需要自动检测项目ID的流程
549
+ if not state:
550
+ log.info("没有找到指定项目的流程,查找自动检测流程")
551
+ # 首先尝试找到已完成的流程(有授权码的)
552
+ completed_flows = []
553
+ for s, data in auth_flows.items():
554
+ if data.get("auto_project_detection", False):
555
+ if user_session and data.get("user_session") == user_session:
556
+ if data.get("code"): # 优先选择已完成的
557
+ completed_flows.append((s, data, data.get("created_at", 0)))
558
+
559
+ # 如果有已完成的流程,选择最新的
560
+ if completed_flows:
561
+ completed_flows.sort(key=lambda x: x[2], reverse=True) # 按时间倒序
562
+ state, flow_data, _ = completed_flows[0]
563
+ log.info(f"找到已完成的最新认证流程: {state}")
564
+ else:
565
+ # 如果没有已完成的,找最新的未完成流程
566
+ pending_flows = []
567
+ for s, data in auth_flows.items():
568
+ if data.get("auto_project_detection", False):
569
+ if user_session and data.get("user_session") == user_session:
570
+ pending_flows.append((s, data, data.get("created_at", 0)))
571
+ elif not user_session:
572
+ pending_flows.append((s, data, data.get("created_at", 0)))
573
+
574
+ if pending_flows:
575
+ pending_flows.sort(key=lambda x: x[2], reverse=True) # 按时间倒序
576
+ state, flow_data, _ = pending_flows[0]
577
+ log.info(f"找到最新的待完成认证流程: {state}")
578
+
579
+ if not state or not flow_data:
580
+ log.error(f"未找到认证流程: state={state}, flow_data存在={bool(flow_data)}")
581
+ log.debug(f"当前所有flow_data: {list(auth_flows.keys())}")
582
+ return {"success": False, "error": "未找到对应的认证流程,请先点击获取认证链接"}
583
+
584
+ log.info(f"找到认证流程: state={state}")
585
+ log.info(
586
+ f"flow_data内容: project_id={flow_data.get('project_id')}, auto_project_detection={flow_data.get('auto_project_detection')}"
587
+ )
588
+ log.info(f"传入的project_id参数: {project_id}")
589
+
590
+ # 如果需要自动检测项目ID且没有提供项目ID
591
+ log.info(
592
+ f"检查auto_project_detection条件: auto_project_detection={flow_data.get('auto_project_detection', False)}, not project_id={not project_id}"
593
+ )
594
+ if flow_data.get("auto_project_detection", False) and not project_id:
595
+ log.info("跳过自动检测项目ID,进入等待阶段")
596
+ elif not project_id:
597
+ log.info("进入project_id检查分支")
598
+ project_id = flow_data.get("project_id")
599
+ if not project_id:
600
+ project_id = DEFAULT_PROJECT_ID
601
+ flow_data["project_id"] = project_id
602
+ log.warning(f"缺少项目ID,使用默认project_id: {project_id}")
603
+ else:
604
+ log.info(f"使用提供的项目ID: {project_id}")
605
+
606
+ # 检查是否已经有授权码
607
+ log.info("开始检查OAuth授权码...")
608
+ log.info(f"等待state={state}的授权回调,回调端口: {flow_data.get('callback_port')}")
609
+ log.info(f"当前flow_data状态: completed={flow_data.get('completed')}, code存在={bool(flow_data.get('code'))}")
610
+ max_wait_time = 60 # 最多等待60秒
611
+ wait_interval = 1 # 每秒检查一次
612
+ waited = 0
613
+
614
+ while waited < max_wait_time:
615
+ if flow_data.get("code"):
616
+ log.info(f"检测到OAuth授权码,开始处理���证 (等待时间: {waited}秒)")
617
+ break
618
+
619
+ # 每5秒输出一次提示
620
+ if waited % 5 == 0 and waited > 0:
621
+ log.info(f"仍在等待OAuth授权... ({waited}/{max_wait_time}秒)")
622
+ log.debug(f"当前state: {state}, flow_data keys: {list(flow_data.keys())}")
623
+
624
+ # 异步等待
625
+ await asyncio.sleep(wait_interval)
626
+ waited += wait_interval
627
+
628
+ # 刷新flow_data引用,因为可能被回调更新了
629
+ if state in auth_flows:
630
+ flow_data = auth_flows[state]
631
+
632
+ if not flow_data.get("code"):
633
+ log.error(f"等待OAuth回调超时,等待了{waited}秒")
634
+ return {
635
+ "success": False,
636
+ "error": "等待OAuth回调超时,请确保完成了浏览器中的认证并看到成功页面",
637
+ }
638
+
639
+ flow = flow_data["flow"]
640
+ auth_code = flow_data["code"]
641
+
642
+ log.info(f"开始使用授权码获取凭证: code={'***' + auth_code[-4:] if auth_code else 'None'}")
643
+
644
+ # 使用认证代码获取凭证
645
+ with _OAuthLibPatcher():
646
+ try:
647
+ log.info("调用flow.exchange_code...")
648
+ credentials = await flow.exchange_code(auth_code)
649
+ log.info(
650
+ f"成功获取凭证,token前缀: {credentials.access_token[:20] if credentials.access_token else 'None'}..."
651
+ )
652
+
653
+ log.info(
654
+ f"检查是否需要项目检测: auto_project_detection={flow_data.get('auto_project_detection')}, project_id={project_id}"
655
+ )
656
+
657
+ # 检查凭证模式
658
+ cred_mode = flow_data.get("mode", "geminicli") if flow_data.get("mode") else mode
659
+ if cred_mode == "antigravity":
660
+ log.info("Antigravity模式:从API获取project_id...")
661
+ # 使用API获取project_id
662
+ antigravity_url = await get_antigravity_api_url()
663
+ project_id, subscription_tier = await fetch_project_id_and_tier(
664
+ credentials.access_token,
665
+ ANTIGRAVITY_USER_AGENT,
666
+ antigravity_url
667
+ )
668
+ if project_id:
669
+ log.info(f"成功从API获取project_id: {project_id}, tier: {subscription_tier}")
670
+ else:
671
+ project_id = DEFAULT_PROJECT_ID
672
+ log.warning(f"无法从API获取project_id,使用默认project_id: {project_id}")
673
+
674
+ # 保存antigravity凭证
675
+ saved_filename = await save_credentials(credentials, project_id, mode="antigravity", subscription_tier=subscription_tier)
676
+
677
+ # 准备返回的凭证数据
678
+ creds_data = _prepare_credentials_data(credentials, project_id, mode="antigravity", subscription_tier=subscription_tier)
679
+
680
+ # 清理使用过的流程
681
+ _cleanup_auth_flow_server(state)
682
+
683
+ log.info("Antigravity OAuth认证成功,凭证已保存")
684
+ return {
685
+ "success": True,
686
+ "credentials": creds_data,
687
+ "file_path": saved_filename,
688
+ "auto_detected_project": False,
689
+ "mode": "antigravity",
690
+ }
691
+
692
+ # 如果需要自动检测项目ID且没有提供项目ID(标准模式)
693
+ if flow_data.get("auto_project_detection", False) and not project_id:
694
+ log.info("标准模式:从API获取project_id...")
695
+ # 使用API获取project_id(使用标准模式的User-Agent)
696
+ code_assist_url = await get_code_assist_endpoint()
697
+ project_id, subscription_tier = await fetch_project_id_and_tier(
698
+ credentials.access_token,
699
+ GEMINICLI_USER_AGENT,
700
+ code_assist_url
701
+ )
702
+ if project_id:
703
+ flow_data["project_id"] = project_id
704
+ log.info(f"成功从API获取project_id: {project_id}")
705
+ # 自动启用必需的API服务
706
+ log.info("正在自动启用必需的API服务...")
707
+ await enable_required_apis(credentials, project_id)
708
+ else:
709
+ log.warning("无法从API获取project_id,回退到项目列表获取方式")
710
+ # 回退到原来的项目列表获取方式
711
+ user_projects = await get_user_projects(credentials)
712
+
713
+ if user_projects:
714
+ # 如果只有一个项目,自动使用
715
+ if len(user_projects) == 1:
716
+ # Google API returns projectId in camelCase
717
+ project_id = user_projects[0].get("projectId")
718
+ if project_id:
719
+ flow_data["project_id"] = project_id
720
+ log.info(f"自动选择唯一项目: {project_id}")
721
+ # 自动启用必需的API服务
722
+ log.info("正在自动启用必需的API服务...")
723
+ await enable_required_apis(credentials, project_id)
724
+ # 如果有多个项目,尝试选择默认项目
725
+ else:
726
+ project_id = await select_default_project(user_projects)
727
+ if project_id:
728
+ flow_data["project_id"] = project_id
729
+ log.info(f"自动选择默认项目: {project_id}")
730
+ # 自动启用必需的API服务
731
+ log.info("正在自动启用必需的API服务...")
732
+ await enable_required_apis(credentials, project_id)
733
+ else:
734
+ # 返回项目列表让用户选择
735
+ return {
736
+ "success": False,
737
+ "error": "请从以下项目中选择一个",
738
+ "requires_project_selection": True,
739
+ "available_projects": [
740
+ {
741
+ # Google API returns projectId in camelCase
742
+ "project_id": p.get("projectId"),
743
+ "name": p.get("displayName") or p.get("projectId"),
744
+ "projectNumber": p.get("projectNumber"),
745
+ }
746
+ for p in user_projects
747
+ ],
748
+ }
749
+ else:
750
+ # 如果无法获取项目列表,使用默认project_id
751
+ project_id = DEFAULT_PROJECT_ID
752
+ flow_data["project_id"] = project_id
753
+ log.warning(f"无法获取项目列表,使用默认project_id: {project_id}")
754
+ elif project_id:
755
+ # 如果已经有项目ID(手动提供或环境检测),也尝试启用API服务
756
+ log.info("正在为已提供的项目ID自动启用必需的API服务...")
757
+ await enable_required_apis(credentials, project_id)
758
+
759
+ # 如果仍然没有项目ID,返回错误
760
+ if not project_id:
761
+ project_id = DEFAULT_PROJECT_ID
762
+ flow_data["project_id"] = project_id
763
+ log.warning(f"仍未获取到project_id,使用默认project_id: {project_id}")
764
+
765
+ # 保存凭证
766
+ saved_filename = await save_credentials(credentials, project_id)
767
+
768
+ # 准备返回的凭证数据
769
+ creds_data = _prepare_credentials_data(credentials, project_id, mode="geminicli")
770
+
771
+ # 清理使用过的流程
772
+ _cleanup_auth_flow_server(state)
773
+
774
+ log.info("OAuth认证成功,凭证已保存")
775
+ return {
776
+ "success": True,
777
+ "credentials": creds_data,
778
+ "file_path": saved_filename,
779
+ "auto_detected_project": flow_data.get("auto_project_detection", False),
780
+ }
781
+
782
+ except Exception as e:
783
+ log.error(f"获取凭证失败: {e}")
784
+ return {"success": False, "error": f"获取凭证失败: {str(e)}"}
785
+
786
+ except Exception as e:
787
+ log.error(f"异步完成认证流程失败: {e}")
788
+ return {"success": False, "error": str(e)}
789
+
790
+
791
+ async def complete_auth_flow_from_callback_url(
792
+ callback_url: str, project_id: Optional[str] = None, mode: str = "geminicli"
793
+ ) -> Dict[str, Any]:
794
+ """从回调URL直接完成认证流程,无需启动本地服务器"""
795
+ try:
796
+ log.info(f"开始从回调URL完成认证: {callback_url}")
797
+
798
+ # 解析回调URL
799
+ parsed_url = urlparse(callback_url)
800
+ query_params = parse_qs(parsed_url.query)
801
+
802
+ # 验证必要参数
803
+ if "state" not in query_params or "code" not in query_params:
804
+ return {"success": False, "error": "回调URL缺少必要参数 (state 或 code)"}
805
+
806
+ state = query_params["state"][0]
807
+ code = query_params["code"][0]
808
+
809
+ log.info(f"从URL解析到: state={state}, code=xxx...")
810
+
811
+ # 检查是否有对应的认证流程
812
+ if state not in auth_flows:
813
+ return {
814
+ "success": False,
815
+ "error": f"未找到对应的认证流程,请先启动认证 (state: {state})",
816
+ }
817
+
818
+ flow_data = auth_flows[state]
819
+ flow = flow_data["flow"]
820
+
821
+ # 构造回调URL(使用flow中存储的redirect_uri)
822
+ redirect_uri = flow.redirect_uri
823
+ log.info(f"使用redirect_uri: {redirect_uri}")
824
+
825
+ try:
826
+ # 使用authorization code获取token
827
+ credentials = await flow.exchange_code(code)
828
+ log.info("成功获取访问令牌")
829
+
830
+ # 检查凭证模式
831
+ cred_mode = flow_data.get("mode", "geminicli") if flow_data.get("mode") else mode
832
+ if cred_mode == "antigravity":
833
+ log.info("Antigravity模式(从回调URL):从API获取project_id...")
834
+ # 使用API获取project_id
835
+ antigravity_url = await get_antigravity_api_url()
836
+ project_id, subscription_tier = await fetch_project_id_and_tier(
837
+ credentials.access_token,
838
+ ANTIGRAVITY_USER_AGENT,
839
+ antigravity_url
840
+ )
841
+ if project_id:
842
+ log.info(f"成功从API获取project_id: {project_id}, tier: {subscription_tier}")
843
+ else:
844
+ project_id = DEFAULT_PROJECT_ID
845
+ log.warning(f"无法从API获取project_id,使用默认project_id: {project_id}")
846
+
847
+ # 保存antigravity凭证
848
+ saved_filename = await save_credentials(credentials, project_id, mode="antigravity", subscription_tier=subscription_tier)
849
+
850
+ # 准备返回的凭证数据
851
+ creds_data = _prepare_credentials_data(credentials, project_id, mode="antigravity", subscription_tier=subscription_tier)
852
+
853
+ # 清理使用过的流程
854
+ _cleanup_auth_flow_server(state)
855
+
856
+ log.info("从回调URL完成Antigravity OAuth认证成功,凭证已保存")
857
+ return {
858
+ "success": True,
859
+ "credentials": creds_data,
860
+ "file_path": saved_filename,
861
+ "auto_detected_project": False,
862
+ "mode": "antigravity",
863
+ }
864
+
865
+ # 标准模式的项目ID处理逻辑
866
+ detected_project_id = None
867
+ auto_detected = False
868
+ subscription_tier = None
869
+
870
+ if not project_id:
871
+ # 尝试使用fetch_project_id_and_tier自动获取项目ID
872
+ try:
873
+ log.info("标准模式:从API获取project_id...")
874
+ code_assist_url = await get_code_assist_endpoint()
875
+ detected_project_id, subscription_tier = await fetch_project_id_and_tier(
876
+ credentials.access_token,
877
+ GEMINICLI_USER_AGENT,
878
+ code_assist_url
879
+ )
880
+ if detected_project_id:
881
+ auto_detected = True
882
+ log.info(f"成功从API获取project_id: {detected_project_id}, tier: {subscription_tier}")
883
+ else:
884
+ log.warning("无法从API获取project_id,回退到项目列表获取方式")
885
+ # 回退到原来的项目列表获取方式
886
+ projects = await get_user_projects(credentials)
887
+ if projects:
888
+ if len(projects) == 1:
889
+ # 只有一个项目,自动使用
890
+ # Google API returns projectId in camelCase
891
+ detected_project_id = projects[0]["projectId"]
892
+ auto_detected = True
893
+ log.info(f"自动检测到唯一项目ID: {detected_project_id}")
894
+ else:
895
+ # 多个项目,自动选择第一个
896
+ # Google API returns projectId in camelCase
897
+ detected_project_id = projects[0]["projectId"]
898
+ auto_detected = True
899
+ log.info(
900
+ f"检测到{len(projects)}个项目,自动选择第一个: {detected_project_id}"
901
+ )
902
+ log.debug(f"其他可用项目: {[p['projectId'] for p in projects[1:]]}")
903
+ else:
904
+ # 没有项目访问权限,使用默认project_id
905
+ detected_project_id = DEFAULT_PROJECT_ID
906
+ auto_detected = False
907
+ log.warning(f"未检测到可访问项目,使用默认project_id: {detected_project_id}")
908
+ except Exception as e:
909
+ log.warning(f"自动检测项目ID失败: {e},��用默认project_id")
910
+ detected_project_id = DEFAULT_PROJECT_ID
911
+ auto_detected = False
912
+ else:
913
+ detected_project_id = project_id
914
+
915
+ # 启用必需的API服务
916
+ if detected_project_id:
917
+ try:
918
+ log.info(f"正在为项目 {detected_project_id} 启用必需的API服务...")
919
+ await enable_required_apis(credentials, detected_project_id)
920
+ except Exception as e:
921
+ log.warning(f"启用API服务失败: {e}")
922
+
923
+ # 保存凭证
924
+ saved_filename = await save_credentials(credentials, detected_project_id, subscription_tier=subscription_tier)
925
+
926
+ # 准备返回的凭证数据
927
+ creds_data = _prepare_credentials_data(credentials, detected_project_id, mode="geminicli", subscription_tier=subscription_tier)
928
+
929
+ # 清理使用过的流程
930
+ _cleanup_auth_flow_server(state)
931
+
932
+ log.info("从回调URL完成OAuth认证成功,凭证已保存")
933
+ return {
934
+ "success": True,
935
+ "credentials": creds_data,
936
+ "file_path": saved_filename,
937
+ "auto_detected_project": auto_detected,
938
+ }
939
+
940
+ except Exception as e:
941
+ log.error(f"从回调URL获取凭证失败: {e}")
942
+ return {"success": False, "error": f"获取凭证失败: {str(e)}"}
943
+
944
+ except Exception as e:
945
+ log.error(f"从回调URL完成认证流程失败: {e}")
946
+ return {"success": False, "error": str(e)}
947
+
948
+
949
+ async def save_credentials(creds: Credentials, project_id: str, mode: str = "geminicli", subscription_tier: str = None) -> str:
950
+ """通过统一存储系统保存凭证"""
951
+ # 生成文件名(使用project_id和时间戳)
952
+ timestamp = int(time.time())
953
+
954
+ # antigravity模式使用特殊前缀
955
+ if mode == "antigravity":
956
+ filename = f"ag_{project_id}-{timestamp}.json"
957
+ else:
958
+ filename = f"{project_id}-{timestamp}.json"
959
+
960
+ # 准备凭证数据
961
+ creds_data = _prepare_credentials_data(creds, project_id, mode, subscription_tier)
962
+
963
+ # 通过存储适配器保存
964
+ storage_adapter = await get_storage_adapter()
965
+ success = await storage_adapter.store_credential(filename, creds_data, mode=mode)
966
+
967
+ if success:
968
+ # 创建默认状态记录
969
+ try:
970
+ default_state = {
971
+ "error_codes": [],
972
+ "disabled": False,
973
+ "last_success": time.time(),
974
+ "user_email": None,
975
+ "tier": subscription_tier,
976
+ }
977
+ await storage_adapter.update_credential_state(filename, default_state, mode=mode)
978
+ log.info(f"凭证和状态已保存到: {filename} (mode={mode})")
979
+ except Exception as e:
980
+ log.warning(f"创建默认状态记录失败 {filename}: {e}")
981
+
982
+ return filename
983
+ else:
984
+ raise Exception(f"保存凭证失败: {filename}")
985
+
986
+
987
+ def async_shutdown_server(server, port):
988
+ """异步关闭OAuth回调服务器,避免阻塞主流程"""
989
+
990
+ def shutdown_server_async():
991
+ try:
992
+ # 设置一个标志来跟踪关闭状态
993
+ shutdown_completed = threading.Event()
994
+
995
+ def do_shutdown():
996
+ try:
997
+ server.shutdown()
998
+ server.server_close()
999
+ shutdown_completed.set()
1000
+ log.info(f"已关闭端口 {port} 的OAuth回调服务器")
1001
+ except Exception as e:
1002
+ shutdown_completed.set()
1003
+ log.debug(f"关闭服务器时出错: {e}")
1004
+
1005
+ # 在单独线程中执行关闭操作
1006
+ shutdown_worker = threading.Thread(target=do_shutdown, daemon=True)
1007
+ shutdown_worker.start()
1008
+
1009
+ # 等待最多5秒,如果超时就放弃等待
1010
+ if shutdown_completed.wait(timeout=5):
1011
+ log.debug(f"端口 {port} 服务器关闭完成")
1012
+ else:
1013
+ log.warning(f"端口 {port} 服务器关闭超时,但不阻塞主流程")
1014
+
1015
+ except Exception as e:
1016
+ log.debug(f"异步关闭服务器时出错: {e}")
1017
+
1018
+ # 在后台线程中关闭服务器,不阻塞主流程
1019
+ shutdown_thread = threading.Thread(target=shutdown_server_async, daemon=True)
1020
+ shutdown_thread.start()
1021
+ log.debug(f"开始异步关闭端口 {port} 的OAuth回调服务器")
1022
+
1023
+
1024
+ def cleanup_expired_flows():
1025
+ """清理过期的认证流程"""
1026
+ current_time = time.time()
1027
+ EXPIRY_TIME = 600 # 10分钟过期
1028
+
1029
+ # 直接遍历删除,避免创建额外列表
1030
+ states_to_remove = [
1031
+ state
1032
+ for state, flow_data in auth_flows.items()
1033
+ if current_time - flow_data["created_at"] > EXPIRY_TIME
1034
+ ]
1035
+
1036
+ # 批量清理,提高效率
1037
+ cleaned_count = 0
1038
+ for state in states_to_remove:
1039
+ flow_data = auth_flows.get(state)
1040
+ if flow_data:
1041
+ # 快速关闭可能存在的服务器
1042
+ try:
1043
+ if flow_data.get("server"):
1044
+ server = flow_data["server"]
1045
+ port = flow_data.get("callback_port")
1046
+ async_shutdown_server(server, port)
1047
+ except Exception as e:
1048
+ log.debug(f"清理过期流程时启动异步关闭服务器失败: {e}")
1049
+
1050
+ # 显式清理流程数据,释放内存
1051
+ flow_data.clear()
1052
+ del auth_flows[state]
1053
+ cleaned_count += 1
1054
+
1055
+ if cleaned_count > 0:
1056
+ log.info(f"清理了 {cleaned_count} 个过期的认证流程")
1057
+
1058
+ # 更积极的垃圾回收触发条件
1059
+ if len(auth_flows) > 20: # 降低阈值
1060
+ import gc
1061
+
1062
+ gc.collect()
1063
+ log.debug(f"触发垃圾回收,当前活跃认证流程数: {len(auth_flows)}")
1064
+
1065
+
1066
+ def get_auth_status(project_id: str) -> Dict[str, Any]:
1067
+ """获取认证状态"""
1068
+ for state, flow_data in auth_flows.items():
1069
+ if flow_data["project_id"] == project_id:
1070
+ return {
1071
+ "status": "completed" if flow_data["completed"] else "pending",
1072
+ "state": state,
1073
+ "created_at": flow_data["created_at"],
1074
+ }
1075
+
1076
+ return {"status": "not_found"}
1077
+
1078
+
1079
+ # 鉴权功能 - 使用更小的数据结构
1080
+ auth_tokens = {} # 存储有效的认证令牌
1081
+ TOKEN_EXPIRY = 3600 # 1小时令牌过期时间
1082
+
1083
+
1084
+ async def verify_password(password: str) -> bool:
1085
+ """验证密码(面板登录使用)"""
1086
+ from config import get_panel_password
1087
+
1088
+ correct_password = await get_panel_password()
1089
+ return password == correct_password
src/converter/anthropic2gemini.py ADDED
@@ -0,0 +1,1288 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Anthropic 到 Gemini 格式转换器
3
+
4
+ 提供请求体、响应和流式转换的完整功能。
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import os
10
+ import uuid
11
+ from typing import Any, AsyncIterator, Dict, List, Optional
12
+
13
+ from fastapi import Response
14
+ from log import log
15
+ from src.converter.utils import merge_system_messages
16
+
17
+ from src.converter.thoughtSignature_fix import (
18
+ decode_tool_id_and_signature,
19
+ is_internal_placeholder_text,
20
+ is_skip_thought_signature_placeholder,
21
+ SKIP_THOUGHT_SIGNATURE_VALIDATOR,
22
+ )
23
+
24
+ DEFAULT_TEMPERATURE = 0.4
25
+ _DEBUG_TRUE = {"1", "true", "yes", "on"}
26
+
27
+ # ============================================================================
28
+ # Thinking 块验证和清理
29
+ # ============================================================================
30
+
31
+ # 最小有效签名长度
32
+ MIN_SIGNATURE_LENGTH = 10
33
+
34
+
35
+ def has_valid_thoughtsignature(block: Dict[str, Any]) -> bool:
36
+ """
37
+ 检查 thinking 块是否有有效签名
38
+
39
+ Args:
40
+ block: content block 字典
41
+
42
+ Returns:
43
+ bool: 是否有有效签名
44
+ """
45
+ if not isinstance(block, dict):
46
+ return True
47
+
48
+ block_type = block.get("type")
49
+ if block_type not in ("thinking", "redacted_thinking"):
50
+ return True # 非 thinking 块默认有效
51
+
52
+ thinking = block.get("thinking", "")
53
+ thoughtsignature = block.get("thoughtSignature")
54
+
55
+ # 空 thinking + 任意 thoughtsignature = 有效 (trailing signature case)
56
+ if not thinking and thoughtsignature is not None:
57
+ return True
58
+
59
+ # 有内容 + 足够长度的 thoughtsignature = 有效
60
+ if thoughtsignature and isinstance(thoughtsignature, str) and len(thoughtsignature) >= MIN_SIGNATURE_LENGTH:
61
+ return True
62
+
63
+ return False
64
+
65
+
66
+ def sanitize_thinking_block(block: Dict[str, Any]) -> Dict[str, Any]:
67
+ """
68
+ 清理 thinking 块,只保留必要字段(移除 cache_control 等)
69
+
70
+ Args:
71
+ block: content block 字典
72
+
73
+ Returns:
74
+ 清理后的 block 字典
75
+ """
76
+ if not isinstance(block, dict):
77
+ return block
78
+
79
+ block_type = block.get("type")
80
+ if block_type not in ("thinking", "redacted_thinking"):
81
+ return block
82
+
83
+ # 重建块,移除额外字段
84
+ sanitized: Dict[str, Any] = {
85
+ "type": block_type,
86
+ "thinking": block.get("thinking", "")
87
+ }
88
+
89
+ thoughtsignature = block.get("thoughtSignature")
90
+ if thoughtsignature:
91
+ sanitized["thoughtSignature"] = thoughtsignature
92
+
93
+ return sanitized
94
+
95
+
96
+ def remove_trailing_unsigned_thinking(blocks: List[Dict[str, Any]]) -> None:
97
+ """
98
+ 移除尾部的无签名 thinking 块
99
+
100
+ Args:
101
+ blocks: content blocks 列表 (会被修改)
102
+ """
103
+ if not blocks:
104
+ return
105
+
106
+ # 从后向前扫描
107
+ end_index = len(blocks)
108
+ for i in range(len(blocks) - 1, -1, -1):
109
+ block = blocks[i]
110
+ if not isinstance(block, dict):
111
+ break
112
+
113
+ block_type = block.get("type")
114
+ if block_type in ("thinking", "redacted_thinking"):
115
+ if not has_valid_thoughtsignature(block):
116
+ end_index = i
117
+ else:
118
+ break # 遇到有效签名的 thinking 块,停止
119
+ else:
120
+ break # 遇到非 thinking 块,停止
121
+
122
+ if end_index < len(blocks):
123
+ removed = len(blocks) - end_index
124
+ del blocks[end_index:]
125
+ log.debug(f"Removed {removed} trailing unsigned thinking block(s)")
126
+
127
+
128
+ def filter_invalid_thinking_blocks(messages: List[Dict[str, Any]]) -> None:
129
+ """
130
+ 过滤消息中的无效 thinking 块,并清理所有 thinking 块的额外字段(如 cache_control)
131
+
132
+ Args:
133
+ messages: Anthropic messages 列表 (会被修改)
134
+ """
135
+ total_filtered = 0
136
+
137
+ for msg in messages:
138
+ # 只处理 assistant 和 model 消息
139
+ role = msg.get("role", "")
140
+ if role not in ("assistant", "model"):
141
+ continue
142
+
143
+ content = msg.get("content")
144
+ if not isinstance(content, list):
145
+ continue
146
+
147
+ original_len = len(content)
148
+ new_blocks: List[Dict[str, Any]] = []
149
+
150
+ for block in content:
151
+ if not isinstance(block, dict):
152
+ new_blocks.append(block)
153
+ continue
154
+
155
+ block_type = block.get("type")
156
+ if block_type not in ("thinking", "redacted_thinking"):
157
+ new_blocks.append(block)
158
+ continue
159
+
160
+ # 所有 thinking 块都需要清理(移除 cache_control 等额外字段)
161
+ # 检查 thinking 块的有效性
162
+ if has_valid_thoughtsignature(block):
163
+ # 有效签名,清理后保留
164
+ new_blocks.append(sanitize_thinking_block(block))
165
+ else:
166
+ # 无效签名,将内容转换为 text 块
167
+ thinking_text = block.get("thinking", "")
168
+ if thinking_text and str(thinking_text).strip():
169
+ log.info(
170
+ f"[Claude-Handler] Converting thinking block with invalid thoughtSignature to text. "
171
+ f"Content length: {len(thinking_text)} chars"
172
+ )
173
+ new_blocks.append({"type": "text", "text": thinking_text})
174
+ else:
175
+ log.debug("[Claude-Handler] Dropping empty thinking block with invalid thoughtSignature")
176
+
177
+ msg["content"] = new_blocks
178
+ filtered_count = original_len - len(new_blocks)
179
+ total_filtered += filtered_count
180
+
181
+ # 如果过滤后为空,添加一个空文本块以保持消息有效
182
+ if not new_blocks:
183
+ msg["content"] = [{"type": "text", "text": ""}]
184
+
185
+ if total_filtered > 0:
186
+ log.debug(f"Filtered {total_filtered} invalid thinking block(s) from history")
187
+
188
+
189
+ # ============================================================================
190
+ # 请求验证和提取
191
+ # ============================================================================
192
+
193
+
194
+ def _anthropic_debug_enabled() -> bool:
195
+ """检查是否启用 Anthropic 调试模式"""
196
+ return str(os.getenv("ANTHROPIC_DEBUG", "true")).strip().lower() in _DEBUG_TRUE
197
+
198
+
199
+ def _cached_content_token_count(usage_metadata: Any) -> int:
200
+ if not isinstance(usage_metadata, dict):
201
+ return 0
202
+ return int(usage_metadata.get("cachedContentTokenCount", 0) or 0)
203
+
204
+
205
+ def _anthropic_usage_from_metadata(usage_metadata: Any) -> Dict[str, int]:
206
+ if not isinstance(usage_metadata, dict):
207
+ return {"input_tokens": 0, "output_tokens": 0}
208
+
209
+ prompt_tokens_total = int(usage_metadata.get("promptTokenCount", 0) or 0)
210
+ cached_tokens = _cached_content_token_count(usage_metadata)
211
+ usage = {
212
+ "input_tokens": max(prompt_tokens_total - cached_tokens, 0),
213
+ "output_tokens": int(usage_metadata.get("candidatesTokenCount", 0) or 0),
214
+ }
215
+
216
+ if cached_tokens > 0:
217
+ usage["cache_read_input_tokens"] = cached_tokens
218
+
219
+ return usage
220
+
221
+
222
+ def _is_non_whitespace_text(value: Any) -> bool:
223
+ """
224
+ 判断文本是否包含"非空白"内容。
225
+
226
+ 说明:下游(Antigravity/Claude 兼容层)会对纯 text 内容块做校验:
227
+ - text 不能为空字符串
228
+ - text 不能仅由空白字符(空格/换行/制表等)组成
229
+ """
230
+ if value is None:
231
+ return False
232
+ try:
233
+ return bool(str(value).strip())
234
+ except Exception:
235
+ return False
236
+
237
+
238
+ def _remove_nulls_for_tool_input(value: Any) -> Any:
239
+ """
240
+ 递归移除 dict/list 中值为 null/None 的字段/元素。
241
+
242
+ 背景:Roo/Kilo 在 Anthropic native tool 路径下,若收到 tool_use.input 中包含 null,
243
+ 可能会把 null 当作真实入参执行(例如"在 null 中搜索")。
244
+ """
245
+ if isinstance(value, dict):
246
+ cleaned: Dict[str, Any] = {}
247
+ for k, v in value.items():
248
+ if v is None:
249
+ continue
250
+ cleaned[k] = _remove_nulls_for_tool_input(v)
251
+ return cleaned
252
+
253
+ if isinstance(value, list):
254
+ cleaned_list = []
255
+ for item in value:
256
+ if item is None:
257
+ continue
258
+ cleaned_list.append(_remove_nulls_for_tool_input(item))
259
+ return cleaned_list
260
+
261
+ return value
262
+
263
+ # ============================================================================
264
+ # 2. JSON Schema 清理
265
+ # ============================================================================
266
+
267
+ def clean_json_schema(schema: Any) -> Any:
268
+ """
269
+ 清理 JSON Schema,移除下游不支持的字段,并把验证要求追加到 description。
270
+ """
271
+ if not isinstance(schema, dict):
272
+ return schema
273
+
274
+ # 下游不支持的字段
275
+ unsupported_keys = {
276
+ "$schema", "$id", "$ref", "$defs", "definitions", "title",
277
+ "example", "examples", "readOnly", "writeOnly", "default",
278
+ "exclusiveMaximum", "exclusiveMinimum", "oneOf", "anyOf", "allOf",
279
+ "const", "additionalItems", "contains", "patternProperties",
280
+ "dependencies", "propertyNames", "if", "then", "else",
281
+ "contentEncoding", "contentMediaType", "nullable",
282
+ }
283
+
284
+ validation_fields = {
285
+ "minLength": "minLength",
286
+ "maxLength": "maxLength",
287
+ "minimum": "minimum",
288
+ "maximum": "maximum",
289
+ "minItems": "minItems",
290
+ "maxItems": "maxItems",
291
+ }
292
+ fields_to_remove = {"additionalProperties"}
293
+
294
+ validations: List[str] = []
295
+ for field, label in validation_fields.items():
296
+ if field in schema:
297
+ validations.append(f"{label}: {schema[field]}")
298
+
299
+ cleaned: Dict[str, Any] = {}
300
+ for key, value in schema.items():
301
+ if key in unsupported_keys or key in fields_to_remove or key in validation_fields:
302
+ continue
303
+
304
+ if key == "type" and isinstance(value, list):
305
+ # type: ["string", "null"] -> type: "string", nullable: true
306
+ has_null = any(
307
+ isinstance(t, str) and t.strip() and t.strip().lower() == "null" for t in value
308
+ )
309
+ non_null_types = [
310
+ t.strip()
311
+ for t in value
312
+ if isinstance(t, str) and t.strip() and t.strip().lower() != "null"
313
+ ]
314
+
315
+ cleaned[key] = non_null_types[0] if non_null_types else "string"
316
+ continue
317
+
318
+ if key == "description" and validations:
319
+ cleaned[key] = f"{value} ({', '.join(validations)})"
320
+ elif isinstance(value, dict):
321
+ cleaned[key] = clean_json_schema(value)
322
+ elif isinstance(value, list):
323
+ cleaned[key] = [clean_json_schema(item) if isinstance(item, dict) else item for item in value]
324
+ else:
325
+ cleaned[key] = value
326
+
327
+ if validations and "description" not in cleaned:
328
+ cleaned["description"] = f"Validation: {', '.join(validations)}"
329
+
330
+ # 如果有 properties 但没有显式 type,则补齐为 object
331
+ if "properties" in cleaned and "type" not in cleaned:
332
+ cleaned["type"] = "object"
333
+
334
+ if (
335
+ isinstance(schema.get("properties"), dict)
336
+ and isinstance(cleaned.get("required"), list)
337
+ ):
338
+ nullable_fields = {
339
+ name
340
+ for name, prop in schema["properties"].items()
341
+ if isinstance(prop, dict)
342
+ and isinstance(prop.get("type"), list)
343
+ and any(str(t).lower() == "null" for t in prop["type"])
344
+ }
345
+ if nullable_fields:
346
+ cleaned["required"] = [
347
+ item for item in cleaned["required"]
348
+ if item not in nullable_fields
349
+ ]
350
+ if not cleaned["required"]:
351
+ cleaned.pop("required", None)
352
+
353
+ return cleaned
354
+
355
+
356
+ # ============================================================================
357
+ # 4. Tools 转换
358
+ # ============================================================================
359
+
360
+ def convert_tools(anthropic_tools: Optional[List[Dict[str, Any]]]) -> Optional[List[Dict[str, Any]]]:
361
+ """
362
+ 将 Anthropic tools[] 转换为下游 tools(functionDeclarations)结构。
363
+ """
364
+ if not anthropic_tools:
365
+ return None
366
+
367
+ gemini_tools: List[Dict[str, Any]] = []
368
+ for tool in anthropic_tools:
369
+ name = tool.get("name", "nameless_function")
370
+ description = tool.get("description", "")
371
+ input_schema = tool.get("input_schema", {}) or {}
372
+ parameters = clean_json_schema(input_schema)
373
+
374
+ gemini_tools.append(
375
+ {
376
+ "functionDeclarations": [
377
+ {
378
+ "name": name,
379
+ "description": description,
380
+ "parametersJsonSchema": parameters,
381
+ }
382
+ ]
383
+ }
384
+ )
385
+
386
+ return gemini_tools or None
387
+
388
+
389
+ # ============================================================================
390
+ # 5. Messages 转换
391
+ # ============================================================================
392
+
393
+ def _extract_tool_result_output(content: Any) -> str:
394
+ """从 tool_result.content 中提取输出字符串"""
395
+ if isinstance(content, list):
396
+ if not content:
397
+ return ""
398
+ first = content[0]
399
+ if isinstance(first, dict) and first.get("type") == "text":
400
+ return str(first.get("text", ""))
401
+ return str(first)
402
+ if content is None:
403
+ return ""
404
+ return str(content)
405
+
406
+
407
+ def convert_messages_to_contents(
408
+ messages: List[Dict[str, Any]],
409
+ *,
410
+ include_thinking: bool = True
411
+ ) -> List[Dict[str, Any]]:
412
+ """
413
+ 将 Anthropic messages[] 转换为下游 contents[](role: user/model, parts: [])。
414
+
415
+ Args:
416
+ messages: Anthropic 格式的消息列表
417
+ include_thinking: 是否包含 thinking 块
418
+ """
419
+ contents: List[Dict[str, Any]] = []
420
+
421
+ # 第一遍:构建 tool_use_id -> (name, thoughtsignature) 的映射
422
+ # 注意:存储的是编码后的 ID(可能包含签名)
423
+ tool_use_info: Dict[str, tuple[str, Optional[str]]] = {}
424
+ for msg in messages:
425
+ raw_content = msg.get("content", "")
426
+ if isinstance(raw_content, list):
427
+ for item in raw_content:
428
+ if isinstance(item, dict) and item.get("type") == "tool_use":
429
+ encoded_tool_id = item.get("id")
430
+ tool_name = item.get("name")
431
+ if encoded_tool_id and tool_name:
432
+ # 解码获取原始ID和签名
433
+ original_id, thoughtsignature = decode_tool_id_and_signature(encoded_tool_id)
434
+ # 存储映射:编码ID -> (name, thoughtsignature)
435
+ tool_use_info[str(encoded_tool_id)] = (tool_name, thoughtsignature)
436
+
437
+ for msg in messages:
438
+ role = msg.get("role", "user")
439
+
440
+ # system 消息已经由 merge_system_messages 处理,这里跳过
441
+ if role == "system":
442
+ continue
443
+
444
+ # 支持 'assistant' 和 'model' 角色(Google history usage)
445
+ gemini_role = "model" if role in ("assistant", "model") else "user"
446
+ raw_content = msg.get("content", "")
447
+
448
+ parts: List[Dict[str, Any]] = []
449
+ if isinstance(raw_content, str):
450
+ if _is_non_whitespace_text(raw_content):
451
+ parts = [{"text": str(raw_content)}]
452
+ elif isinstance(raw_content, list):
453
+ for item in raw_content:
454
+ if not isinstance(item, dict):
455
+ if _is_non_whitespace_text(item):
456
+ parts.append({"text": str(item)})
457
+ continue
458
+
459
+ item_type = item.get("type")
460
+ if item_type == "thinking":
461
+ # 不把客户端回传的 thinking signature 再送给 Google。
462
+ # 这些签名很容易在中转/换号/裁剪后变成 Corrupted thought signature。
463
+ continue
464
+ elif item_type == "redacted_thinking":
465
+ continue
466
+ elif item_type == "text":
467
+ text = item.get("text", "")
468
+ if _is_non_whitespace_text(text):
469
+ parts.append({"text": str(text)})
470
+ elif item_type == "image":
471
+ source = item.get("source", {}) or {}
472
+ if source.get("type") == "base64":
473
+ parts.append(
474
+ {
475
+ "inlineData": {
476
+ "mimeType": source.get("media_type", "image/png"),
477
+ "data": source.get("data", ""),
478
+ }
479
+ }
480
+ )
481
+ elif item_type == "tool_use":
482
+ encoded_id = item.get("id") or ""
483
+ original_id, _ = decode_tool_id_and_signature(encoded_id)
484
+
485
+ fc_part: Dict[str, Any] = {
486
+ "functionCall": {
487
+ "id": original_id, # 使用原始ID,不带签名
488
+ "name": item.get("name"),
489
+ "args": item.get("input", {}) or {},
490
+ }
491
+ }
492
+
493
+ fc_part["thoughtSignature"] = SKIP_THOUGHT_SIGNATURE_VALIDATOR
494
+
495
+ parts.append(fc_part)
496
+ elif item_type == "tool_result":
497
+ output = _extract_tool_result_output(item.get("content"))
498
+ encoded_tool_use_id = item.get("tool_use_id") or ""
499
+
500
+ # 解码获取原始ID(functionResponse不需要签名)
501
+ original_tool_use_id, _ = decode_tool_id_and_signature(encoded_tool_use_id)
502
+
503
+ # 从 tool_result 获取 name,如果没有则从映射中查找
504
+ func_name = item.get("name")
505
+ if not func_name and encoded_tool_use_id:
506
+ # 使用编码ID查找映射
507
+ tool_info = tool_use_info.get(str(encoded_tool_use_id))
508
+ if tool_info:
509
+ func_name = tool_info[0] # 获取 name
510
+ if not func_name:
511
+ func_name = "unknown_function"
512
+
513
+ parts.append(
514
+ {
515
+ "functionResponse": {
516
+ "id": original_tool_use_id, # 使用解码后的原始ID以匹配functionCall
517
+ "name": func_name,
518
+ "response": {"output": output},
519
+ }
520
+ }
521
+ )
522
+ else:
523
+ parts.append({"text": json.dumps(item, ensure_ascii=False)})
524
+ else:
525
+ if _is_non_whitespace_text(raw_content):
526
+ parts = [{"text": str(raw_content)}]
527
+
528
+ if not parts:
529
+ continue
530
+
531
+ contents.append({"role": gemini_role, "parts": parts})
532
+
533
+ return contents
534
+
535
+
536
+ def reorganize_tool_messages(contents: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
537
+ """
538
+ 重新组织消息,满足 tool_use/tool_result 约束。
539
+ """
540
+ tool_results: Dict[str, Dict[str, Any]] = {}
541
+
542
+ for msg in contents:
543
+ for part in msg.get("parts", []) or []:
544
+ if isinstance(part, dict) and "functionResponse" in part:
545
+ tool_id = (part.get("functionResponse") or {}).get("id")
546
+ if tool_id:
547
+ tool_results[str(tool_id)] = part
548
+
549
+ flattened: List[Dict[str, Any]] = []
550
+ for msg in contents:
551
+ role = msg.get("role")
552
+ for part in msg.get("parts", []) or []:
553
+ flattened.append({"role": role, "parts": [part]})
554
+
555
+ new_contents: List[Dict[str, Any]] = []
556
+ i = 0
557
+ while i < len(flattened):
558
+ msg = flattened[i]
559
+ part = msg["parts"][0]
560
+
561
+ if isinstance(part, dict) and "functionResponse" in part:
562
+ i += 1
563
+ continue
564
+
565
+ if isinstance(part, dict) and "functionCall" in part:
566
+ tool_id = (part.get("functionCall") or {}).get("id")
567
+ new_contents.append({"role": "model", "parts": [part]})
568
+
569
+ if tool_id is not None and str(tool_id) in tool_results:
570
+ new_contents.append({"role": "user", "parts": [tool_results[str(tool_id)]]})
571
+
572
+ i += 1
573
+ continue
574
+
575
+ new_contents.append(msg)
576
+ i += 1
577
+
578
+ return new_contents
579
+
580
+
581
+ # ============================================================================
582
+ # 7. Tool Choice 转换
583
+ # ============================================================================
584
+
585
+ def convert_tool_choice_to_tool_config(tool_choice: Any) -> Optional[Dict[str, Any]]:
586
+ """
587
+ 将 Anthropic tool_choice 转换为 Gemini toolConfig
588
+
589
+ Args:
590
+ tool_choice: Anthropic 格式的 tool_choice
591
+ - {"type": "auto"}: 模型自动决定是否使用工具
592
+ - {"type": "any"}: 模型必须使用工具
593
+ - {"type": "tool", "name": "tool_name"}: 模型必须使用指定工具
594
+
595
+ Returns:
596
+ Gemini 格式的 toolConfig,如果无效则返回 None
597
+ """
598
+ if not tool_choice:
599
+ return None
600
+
601
+ if isinstance(tool_choice, dict):
602
+ choice_type = tool_choice.get("type")
603
+
604
+ if choice_type == "auto":
605
+ return {"functionCallingConfig": {"mode": "AUTO"}}
606
+ elif choice_type == "any":
607
+ return {"functionCallingConfig": {"mode": "ANY"}}
608
+ elif choice_type == "tool":
609
+ tool_name = tool_choice.get("name")
610
+ if tool_name:
611
+ return {
612
+ "functionCallingConfig": {
613
+ "mode": "ANY",
614
+ "allowedFunctionNames": [tool_name],
615
+ }
616
+ }
617
+
618
+ # 无效或不支持的 tool_choice,返回 None
619
+ return None
620
+
621
+
622
+ # ============================================================================
623
+ # 8. Generation Config 构建
624
+ # ============================================================================
625
+
626
+ def build_generation_config(payload: Dict[str, Any]) -> Dict[str, Any]:
627
+ """
628
+ 根据 Anthropic Messages 请求构造下游 generationConfig。
629
+
630
+ Returns:
631
+ generation_config: 生成配置字典
632
+ """
633
+ config: Dict[str, Any] = {
634
+ "topP": 1,
635
+ "candidateCount": 1,
636
+ "stopSequences": [
637
+ "<|user|>",
638
+ "<|bot|>",
639
+ "<|context_request|>",
640
+ "<|endoftext|>",
641
+ "<|end_of_turn|>",
642
+ ],
643
+ }
644
+
645
+ temperature = payload.get("temperature", None)
646
+ config["temperature"] = DEFAULT_TEMPERATURE if temperature is None else temperature
647
+
648
+ top_p = payload.get("top_p", None)
649
+ if top_p is not None:
650
+ config["topP"] = top_p
651
+
652
+ top_k = payload.get("top_k", None)
653
+ if top_k is not None:
654
+ config["topK"] = top_k
655
+
656
+ max_tokens = payload.get("max_tokens")
657
+ if max_tokens is not None:
658
+ config["maxOutputTokens"] = max_tokens
659
+
660
+ # 处理 extended thinking 参数 (plan mode)
661
+ thinking = payload.get("thinking")
662
+ is_plan_mode = False
663
+ if thinking and isinstance(thinking, dict):
664
+ thinking_type = thinking.get("type")
665
+ budget_tokens = thinking.get("budget_tokens")
666
+
667
+ # 如果启用了 extended thinking,设置 thinkingConfig
668
+ if thinking_type == "enabled":
669
+ is_plan_mode = True
670
+ thinking_config: Dict[str, Any] = {}
671
+
672
+ # 设置思考预算,默认使用较大的值以支持计划模式
673
+ if budget_tokens is not None:
674
+ thinking_config["thinkingBudget"] = budget_tokens
675
+ else:
676
+ # 默认给一个较大的思考预算以支持完整的计划生成
677
+ thinking_config["thinkingBudget"] = 48000
678
+
679
+ # 始终包含思考内容,这样才能看到计划
680
+ thinking_config["includeThoughts"] = True
681
+
682
+ config["thinkingConfig"] = thinking_config
683
+ log.info(f"[ANTHROPIC2GEMINI] Extended thinking enabled with budget: {thinking_config['thinkingBudget']}")
684
+ elif thinking_type == "disabled":
685
+ # 明确禁用思考模式
686
+ config["thinkingConfig"] = {
687
+ "includeThoughts": False
688
+ }
689
+ log.info("[ANTHROPIC2GEMINI] Extended thinking explicitly disabled")
690
+
691
+ stop_sequences = payload.get("stop_sequences")
692
+ if isinstance(stop_sequences, list) and stop_sequences:
693
+ config["stopSequences"] = config["stopSequences"] + [str(s) for s in stop_sequences]
694
+ elif is_plan_mode:
695
+ # Plan mode 时清空默认 stop sequences,避免过早停止
696
+ # 默认的 stop sequences 可能会导致模型在生成计划时过早停止
697
+ config["stopSequences"] = []
698
+ log.info("[ANTHROPIC2GEMINI] Plan mode: cleared default stop sequences to prevent premature stopping")
699
+
700
+ # 如果不是 plan mode 且没有自定义 stop_sequences,保持默认值
701
+ # (默认值已经在 config 初始化时设置)
702
+
703
+ return config
704
+
705
+
706
+ # ============================================================================
707
+ # 8. 主要转换函数
708
+ # ============================================================================
709
+
710
+ async def anthropic_to_gemini_request(payload: Dict[str, Any]) -> Dict[str, Any]:
711
+ """
712
+ 将 Anthropic 格式请求体转换为 Gemini 格式请求体
713
+
714
+ 注意: 此函数只负责基础转换,不包含 normalize_gemini_request 中的处理
715
+ (如 thinking config 自动设置、search tools、参数范围限制等)
716
+
717
+ Args:
718
+ payload: Anthropic 格式的请求体字典
719
+
720
+ Returns:
721
+ Gemini 格式的请求体字典,包含:
722
+ - contents: 转换后的消息内容
723
+ - generationConfig: 生成配置
724
+ - systemInstruction: 系统指令 (如果有)
725
+ - tools: 工具定义 (如果有)
726
+ - toolConfig: 工具调用配置 (如果有 tool_choice)
727
+ """
728
+ # 处理连续的system消息(兼容性模式)
729
+ payload = await merge_system_messages(payload)
730
+
731
+ # 提取和转换基础信息
732
+ messages = payload.get("messages") or []
733
+ if not isinstance(messages, list):
734
+ messages = []
735
+
736
+ # [CRITICAL FIX] 过滤并修复 Thinking 块签名
737
+ # 在转换前先过滤无效的 thinking 块
738
+ filter_invalid_thinking_blocks(messages)
739
+
740
+ # 构建生成配置
741
+ generation_config = build_generation_config(payload)
742
+
743
+ # 转换消息内容(始终包含thinking块,由响应端处理)
744
+ contents = convert_messages_to_contents(messages, include_thinking=True)
745
+
746
+ # [CRITICAL FIX] 移除尾部无签名的 thinking 块
747
+ # 对真实请求应用额外的清理
748
+ for content in contents:
749
+ role = content.get("role", "")
750
+ if role == "model": # 只处理 model/assistant 消息
751
+ parts = content.get("parts", [])
752
+ if isinstance(parts, list):
753
+ remove_trailing_unsigned_thinking(parts)
754
+
755
+ contents = reorganize_tool_messages(contents)
756
+
757
+ # 转换工具
758
+ tools = convert_tools(payload.get("tools"))
759
+
760
+ # 转换 tool_choice
761
+ tool_config = convert_tool_choice_to_tool_config(payload.get("tool_choice"))
762
+
763
+ # 构建基础请求数据
764
+ gemini_request = {
765
+ "contents": contents,
766
+ "generationConfig": generation_config,
767
+ }
768
+
769
+ # 如果 merge_system_messages 已经添加了 systemInstruction,使用它
770
+ if "systemInstruction" in payload:
771
+ gemini_request["systemInstruction"] = payload["systemInstruction"]
772
+
773
+ if tools:
774
+ gemini_request["tools"] = tools
775
+
776
+ # 添加 toolConfig(如果有 tool_choice)
777
+ if tool_config:
778
+ gemini_request["toolConfig"] = tool_config
779
+
780
+ # 透传图片生成的 size 参数(如 "1024x1536")
781
+ if "size" in payload and payload["size"]:
782
+ gemini_request["size"] = payload["size"]
783
+
784
+ return gemini_request
785
+
786
+
787
+ def gemini_to_anthropic_response(
788
+ gemini_response: Dict[str, Any],
789
+ model: str,
790
+ status_code: int = 200
791
+ ) -> Dict[str, Any]:
792
+ """
793
+ 将 Gemini 格式非流式响应转换为 Anthropic 格式非流式响应
794
+
795
+ 注意: 如果收到的不是 200 开头的响应体,不做任何处理,直接转发
796
+
797
+ Args:
798
+ gemini_response: Gemini 格式的响应体字典
799
+ model: 模型名称
800
+ status_code: HTTP 状态码 (默认 200)
801
+
802
+ Returns:
803
+ Anthropic 格式的响应体字典,或原始响应 (如果状态码不是 2xx)
804
+ """
805
+ # 非 2xx 状态码直接返回原始响应
806
+ if not (200 <= status_code < 300):
807
+ return gemini_response
808
+
809
+ # 处理 GeminiCLI 的 response 包装格式
810
+ if "response" in gemini_response:
811
+ response_data = gemini_response["response"]
812
+ else:
813
+ response_data = gemini_response
814
+
815
+ # 提取候选结果
816
+ candidate = response_data.get("candidates", [{}])[0] or {}
817
+ parts = candidate.get("content", {}).get("parts", []) or []
818
+
819
+ # 获取 usage metadata
820
+ usage_metadata = {}
821
+ if "usageMetadata" in response_data:
822
+ usage_metadata = response_data["usageMetadata"]
823
+ elif "usageMetadata" in candidate:
824
+ usage_metadata = candidate["usageMetadata"]
825
+
826
+ # 转换内容块
827
+ content = []
828
+ has_tool_use = False
829
+
830
+ for part in parts:
831
+ if not isinstance(part, dict):
832
+ continue
833
+
834
+ # 处理 thinking 块
835
+ if part.get("thought") is True:
836
+ if is_skip_thought_signature_placeholder(part):
837
+ continue
838
+ thinking_text = part.get("text", "")
839
+ if thinking_text is None:
840
+ thinking_text = ""
841
+
842
+ block: Dict[str, Any] = {"type": "thinking", "thinking": str(thinking_text)}
843
+
844
+ # 如果有 thoughtsignature 则添加
845
+ thoughtsignature = part.get("thoughtSignature")
846
+ if thoughtsignature:
847
+ block["thoughtSignature"] = thoughtsignature
848
+
849
+ content.append(block)
850
+ continue
851
+
852
+ # 处理文本块
853
+ if "text" in part:
854
+ text = part.get("text", "")
855
+ if (
856
+ is_skip_thought_signature_placeholder(part)
857
+ or is_internal_placeholder_text(text)
858
+ ):
859
+ continue
860
+ content.append({"type": "text", "text": text})
861
+ continue
862
+
863
+ # 处理工具调用
864
+ if "functionCall" in part:
865
+ has_tool_use = True
866
+ fc = part.get("functionCall", {}) or {}
867
+ original_id = fc.get("id") or f"toolu_{uuid.uuid4().hex}"
868
+ content.append(
869
+ {
870
+ "type": "tool_use",
871
+ "id": original_id,
872
+ "name": fc.get("name") or "",
873
+ "input": _remove_nulls_for_tool_input(fc.get("args", {}) or {}),
874
+ }
875
+ )
876
+ continue
877
+
878
+ # 处理图片
879
+ if "inlineData" in part:
880
+ inline = part.get("inlineData", {}) or {}
881
+ content.append(
882
+ {
883
+ "type": "image",
884
+ "source": {
885
+ "type": "base64",
886
+ "media_type": inline.get("mimeType", "image/png"),
887
+ "data": inline.get("data", ""),
888
+ },
889
+ }
890
+ )
891
+ continue
892
+
893
+ # 确定停止原因
894
+ finish_reason = candidate.get("finishReason")
895
+
896
+ # 只有在正常停止(STOP)且有工具调用时才设为 tool_use
897
+ # 避免在 SAFETY、MAX_TOKENS 等情况下仍然返回 tool_use 导致循环
898
+ if has_tool_use and finish_reason == "STOP":
899
+ stop_reason = "tool_use"
900
+ elif finish_reason == "MAX_TOKENS":
901
+ stop_reason = "max_tokens"
902
+ else:
903
+ # 其他情况(SAFETY、RECITATION 等)默认为 end_turn
904
+ stop_reason = "end_turn"
905
+
906
+ # 提取 token 使用情况
907
+ usage = _anthropic_usage_from_metadata(usage_metadata)
908
+
909
+ # 构建 Anthropic 响应
910
+ message_id = f"msg_{uuid.uuid4().hex}"
911
+
912
+ return {
913
+ "id": message_id,
914
+ "type": "message",
915
+ "role": "assistant",
916
+ "model": model,
917
+ "content": content,
918
+ "stop_reason": stop_reason,
919
+ "stop_sequence": None,
920
+ "usage": usage,
921
+ }
922
+
923
+
924
+ async def gemini_stream_to_anthropic_stream(
925
+ gemini_stream: AsyncIterator[bytes],
926
+ model: str,
927
+ status_code: int = 200
928
+ ) -> AsyncIterator[bytes]:
929
+ """
930
+ 将 Gemini 格式流式响应转换为 Anthropic SSE 格式流式响应
931
+
932
+ 注意: 如果收到的不是 200 开头的响应体,不做任何处理,直接转发
933
+
934
+ Args:
935
+ gemini_stream: Gemini 格式的流式响应 (bytes 迭代器)
936
+ model: 模型名称
937
+ status_code: HTTP 状态码 (默认 200)
938
+
939
+ Yields:
940
+ Anthropic SSE 格式的响应块 (bytes)
941
+ """
942
+ # 非 2xx 状态码直接转发原始流
943
+ if not (200 <= status_code < 300):
944
+ async for chunk in gemini_stream:
945
+ yield chunk
946
+ return
947
+
948
+ # 初始化状态
949
+ message_id = f"msg_{uuid.uuid4().hex}"
950
+ message_start_sent = False
951
+ current_block_type: Optional[str] = None
952
+ current_block_index = -1
953
+ current_thinking_signature: Optional[str] = None
954
+ has_tool_use = False
955
+ input_tokens = 0
956
+ output_tokens = 0
957
+ cached_input_tokens = 0
958
+ finish_reason: Optional[str] = None
959
+
960
+ def _sse_event(event: str, data: Dict[str, Any]) -> bytes:
961
+ """生成 SSE 事件"""
962
+ payload = json.dumps(data, ensure_ascii=False, separators=(",", ":"))
963
+ return f"event: {event}\ndata: {payload}\n\n".encode("utf-8")
964
+
965
+ def _close_block() -> Optional[bytes]:
966
+ """关闭当前内容块"""
967
+ nonlocal current_block_type
968
+ if current_block_type is None:
969
+ return None
970
+ event = _sse_event(
971
+ "content_block_stop",
972
+ {"type": "content_block_stop", "index": current_block_index},
973
+ )
974
+ current_block_type = None
975
+ return event
976
+
977
+ def _usage_payload() -> Dict[str, int]:
978
+ usage = {"input_tokens": input_tokens, "output_tokens": output_tokens}
979
+ if cached_input_tokens > 0:
980
+ usage["cache_read_input_tokens"] = cached_input_tokens
981
+ return usage
982
+
983
+ # 处理流式数据
984
+ try:
985
+ async for chunk in gemini_stream:
986
+ # 检查是否是 Response 对象(错误情况)
987
+ if isinstance(chunk, Response):
988
+ log.warning(f"[GEMINI_TO_ANTHROPIC] 收到 Response 对象,状态码: {chunk.status_code},直接转发错误")
989
+ # 直接转发错误响应内容,不做格式转换
990
+ error_content = chunk.body if isinstance(chunk.body, bytes) else chunk.body.encode('utf-8')
991
+ yield error_content
992
+ return
993
+
994
+ # 记录接收到的原始chunk
995
+ log.debug(f"[GEMINI_TO_ANTHROPIC] Raw chunk: {chunk[:200] if chunk else b''}")
996
+
997
+ # 解析 Gemini 流式块
998
+ if not chunk or not chunk.startswith(b"data: "):
999
+ log.debug(f"[GEMINI_TO_ANTHROPIC] Skipping chunk (not SSE format or empty)")
1000
+ continue
1001
+
1002
+ raw = chunk[6:].strip()
1003
+ if raw == b"[DONE]":
1004
+ log.debug(f"[GEMINI_TO_ANTHROPIC] Received [DONE] marker")
1005
+ break
1006
+
1007
+ log.debug(f"[GEMINI_TO_ANTHROPIC] Parsing JSON: {raw[:200]}")
1008
+
1009
+ try:
1010
+ data = json.loads(raw.decode('utf-8', errors='ignore'))
1011
+ log.debug(f"[GEMINI_TO_ANTHROPIC] Parsed data: {json.dumps(data, ensure_ascii=False)[:300]}")
1012
+ except Exception as e:
1013
+ log.warning(f"[GEMINI_TO_ANTHROPIC] JSON parse error: {e}")
1014
+ continue
1015
+
1016
+ # 处理 GeminiCLI 的 response 包装格式
1017
+ if "response" in data:
1018
+ response = data["response"]
1019
+ else:
1020
+ response = data
1021
+
1022
+ candidate = (response.get("candidates", []) or [{}])[0] or {}
1023
+ parts = (candidate.get("content", {}) or {}).get("parts", []) or []
1024
+
1025
+ # 更新 usage metadata
1026
+ if "usageMetadata" in response:
1027
+ usage = response["usageMetadata"]
1028
+ if isinstance(usage, dict):
1029
+ if "promptTokenCount" in usage:
1030
+ prompt_tokens_total = int(usage.get("promptTokenCount", 0) or 0)
1031
+ input_tokens = max(prompt_tokens_total - cached_input_tokens, 0)
1032
+ if "candidatesTokenCount" in usage:
1033
+ output_tokens = int(usage.get("candidatesTokenCount", 0) or 0)
1034
+ if "cachedContentTokenCount" in usage:
1035
+ cached_input_tokens = int(usage.get("cachedContentTokenCount", 0) or 0)
1036
+ input_tokens = max(
1037
+ int(usage.get("promptTokenCount", 0) or 0) - cached_input_tokens,
1038
+ 0,
1039
+ )
1040
+
1041
+ # 发送 message_start(仅一次)
1042
+ if not message_start_sent:
1043
+ message_start_sent = True
1044
+ yield _sse_event(
1045
+ "message_start",
1046
+ {
1047
+ "type": "message_start",
1048
+ "message": {
1049
+ "id": message_id,
1050
+ "type": "message",
1051
+ "role": "assistant",
1052
+ "model": model,
1053
+ "content": [],
1054
+ "stop_reason": None,
1055
+ "stop_sequence": None,
1056
+ "usage": _usage_payload(),
1057
+ },
1058
+ },
1059
+ )
1060
+
1061
+ # 处理各种 parts
1062
+ for part in parts:
1063
+ if not isinstance(part, dict):
1064
+ continue
1065
+
1066
+ # 处理 thinking 块
1067
+ if part.get("thought") is True:
1068
+ if is_skip_thought_signature_placeholder(part):
1069
+ continue
1070
+ thinking_text = part.get("text", "")
1071
+ thoughtsignature = part.get("thoughtSignature")
1072
+
1073
+ # 检查是否需要关闭上一个块并开启新的 thinking 块
1074
+ if current_block_type != "thinking":
1075
+ close_evt = _close_block()
1076
+ if close_evt:
1077
+ yield close_evt
1078
+
1079
+ current_block_index += 1
1080
+ current_block_type = "thinking"
1081
+ current_thinking_signature = thoughtsignature
1082
+
1083
+ block: Dict[str, Any] = {"type": "thinking", "thinking": ""}
1084
+ if thoughtsignature:
1085
+ block["thoughtSignature"] = thoughtsignature
1086
+ yield _sse_event(
1087
+ "content_block_start",
1088
+ {
1089
+ "type": "content_block_start",
1090
+ "index": current_block_index,
1091
+ "content_block": block,
1092
+ },
1093
+ )
1094
+ elif thoughtsignature and thoughtsignature != current_thinking_signature:
1095
+ # 签名变化,需要开启新的 thinking 块
1096
+ close_evt = _close_block()
1097
+ if close_evt:
1098
+ yield close_evt
1099
+
1100
+ current_block_index += 1
1101
+ current_block_type = "thinking"
1102
+ current_thinking_signature = thoughtsignature
1103
+
1104
+ block_new: Dict[str, Any] = {"type": "thinking", "thinking": ""}
1105
+ if thoughtsignature:
1106
+ block_new["thoughtSignature"] = thoughtsignature
1107
+
1108
+ yield _sse_event(
1109
+ "content_block_start",
1110
+ {
1111
+ "type": "content_block_start",
1112
+ "index": current_block_index,
1113
+ "content_block": block_new,
1114
+ },
1115
+ )
1116
+
1117
+ # 发送 thinking 文本增量
1118
+ if thinking_text:
1119
+ yield _sse_event(
1120
+ "content_block_delta",
1121
+ {
1122
+ "type": "content_block_delta",
1123
+ "index": current_block_index,
1124
+ "delta": {"type": "thinking_delta", "thinking": thinking_text},
1125
+ },
1126
+ )
1127
+ continue
1128
+
1129
+ # 处理文本块
1130
+ if "text" in part:
1131
+ if (
1132
+ is_skip_thought_signature_placeholder(part)
1133
+ or is_internal_placeholder_text(part.get("text"))
1134
+ ):
1135
+ continue
1136
+ text = part.get("text", "")
1137
+ if isinstance(text, str) and not text.strip():
1138
+ continue
1139
+
1140
+ if current_block_type != "text":
1141
+ close_evt = _close_block()
1142
+ if close_evt:
1143
+ yield close_evt
1144
+
1145
+ current_block_index += 1
1146
+ current_block_type = "text"
1147
+
1148
+ yield _sse_event(
1149
+ "content_block_start",
1150
+ {
1151
+ "type": "content_block_start",
1152
+ "index": current_block_index,
1153
+ "content_block": {"type": "text", "text": ""},
1154
+ },
1155
+ )
1156
+
1157
+ if text:
1158
+ yield _sse_event(
1159
+ "content_block_delta",
1160
+ {
1161
+ "type": "content_block_delta",
1162
+ "index": current_block_index,
1163
+ "delta": {"type": "text_delta", "text": text},
1164
+ },
1165
+ )
1166
+ continue
1167
+
1168
+ # 处理工具调用
1169
+ if "functionCall" in part:
1170
+ close_evt = _close_block()
1171
+ if close_evt:
1172
+ yield close_evt
1173
+
1174
+ has_tool_use = True
1175
+ fc = part.get("functionCall", {}) or {}
1176
+ original_id = fc.get("id") or f"toolu_{uuid.uuid4().hex}"
1177
+ tool_id = original_id
1178
+ tool_name = fc.get("name") or ""
1179
+ tool_args = _remove_nulls_for_tool_input(fc.get("args", {}) or {})
1180
+
1181
+ if _anthropic_debug_enabled():
1182
+ log.info(
1183
+ f"[ANTHROPIC][tool_use] 处理工具调用: name={tool_name}, "
1184
+ f"id={tool_id}"
1185
+ )
1186
+
1187
+ current_block_index += 1
1188
+ # 注意:工具调用不设置 current_block_type,因为它是独立完整的块
1189
+
1190
+ yield _sse_event(
1191
+ "content_block_start",
1192
+ {
1193
+ "type": "content_block_start",
1194
+ "index": current_block_index,
1195
+ "content_block": {
1196
+ "type": "tool_use",
1197
+ "id": tool_id,
1198
+ "name": tool_name,
1199
+ "input": {},
1200
+ },
1201
+ },
1202
+ )
1203
+
1204
+ input_json = json.dumps(tool_args, ensure_ascii=False, separators=(",", ":"))
1205
+ yield _sse_event(
1206
+ "content_block_delta",
1207
+ {
1208
+ "type": "content_block_delta",
1209
+ "index": current_block_index,
1210
+ "delta": {"type": "input_json_delta", "partial_json": input_json},
1211
+ },
1212
+ )
1213
+
1214
+ yield _sse_event(
1215
+ "content_block_stop",
1216
+ {"type": "content_block_stop", "index": current_block_index},
1217
+ )
1218
+ # 工具调用块已完全关闭,current_block_type 保持为 None
1219
+
1220
+ if _anthropic_debug_enabled():
1221
+ log.info(f"[ANTHROPIC][tool_use] 工具调用块已关闭: index={current_block_index}")
1222
+
1223
+ continue
1224
+
1225
+ # 检查是否结束
1226
+ if candidate.get("finishReason"):
1227
+ finish_reason = candidate.get("finishReason")
1228
+ break
1229
+
1230
+ # 关闭最后的内容块
1231
+ close_evt = _close_block()
1232
+ if close_evt:
1233
+ yield close_evt
1234
+
1235
+ # 确定停止原因
1236
+ # 只有在正常停止(STOP)且有工具调用时才设为 tool_use
1237
+ # 避免在 SAFETY、MAX_TOKENS 等情况下仍然返回 tool_use 导致循环
1238
+ if has_tool_use and finish_reason == "STOP":
1239
+ stop_reason = "tool_use"
1240
+ elif finish_reason == "MAX_TOKENS":
1241
+ stop_reason = "max_tokens"
1242
+ else:
1243
+ # 其他情况(SAFETY、RECITATION 等)默认为 end_turn
1244
+ stop_reason = "end_turn"
1245
+
1246
+ if _anthropic_debug_enabled():
1247
+ log.info(
1248
+ f"[ANTHROPIC][stream_end] 流式结束: stop_reason={stop_reason}, "
1249
+ f"has_tool_use={has_tool_use}, finish_reason={finish_reason}, "
1250
+ f"input_tokens={input_tokens}, output_tokens={output_tokens}"
1251
+ )
1252
+
1253
+ # 发送 message_delta 和 message_stop
1254
+ yield _sse_event(
1255
+ "message_delta",
1256
+ {
1257
+ "type": "message_delta",
1258
+ "delta": {"stop_reason": stop_reason, "stop_sequence": None},
1259
+ "usage": _usage_payload(),
1260
+ },
1261
+ )
1262
+
1263
+ yield _sse_event("message_stop", {"type": "message_stop"})
1264
+
1265
+ except Exception as e:
1266
+ log.error(f"[ANTHROPIC] 流式转换失败: {e}")
1267
+ # 发送错误事件
1268
+ if not message_start_sent:
1269
+ yield _sse_event(
1270
+ "message_start",
1271
+ {
1272
+ "type": "message_start",
1273
+ "message": {
1274
+ "id": message_id,
1275
+ "type": "message",
1276
+ "role": "assistant",
1277
+ "model": model,
1278
+ "content": [],
1279
+ "stop_reason": None,
1280
+ "stop_sequence": None,
1281
+ "usage": _usage_payload(),
1282
+ },
1283
+ },
1284
+ )
1285
+ yield _sse_event(
1286
+ "error",
1287
+ {"type": "error", "error": {"type": "api_error", "message": str(e)}},
1288
+ )
src/converter/anti_truncation.py ADDED
@@ -0,0 +1,731 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Anti-Truncation Module - Ensures complete streaming output
3
+ 保持一个流式请求内完整输出的反截断模块
4
+ """
5
+
6
+ import io
7
+ import json
8
+ import re
9
+ from typing import Any, AsyncGenerator, Dict, List, Tuple
10
+
11
+ from fastapi.responses import StreamingResponse
12
+
13
+ from log import log
14
+
15
+ # 反截断配置
16
+ DONE_MARKER = "[done]"
17
+ CONTINUATION_PROMPT = f"""请从刚才被截断的地方继续输出剩余的所有内容。
18
+
19
+ 重要提醒:
20
+ 1. 不要重复前面已经输出的内容
21
+ 2. 直接继续输出,无需任何前言或解释
22
+ 3. 当你完整完成所有内容输出后,必须在最后一行单独输出:{DONE_MARKER}
23
+ 4. {DONE_MARKER} 标记表示你的回答已经完全结束,这是必需的结束标记
24
+
25
+ 现在请继续输出:"""
26
+
27
+ # 正则替换配置
28
+ REGEX_REPLACEMENTS: List[Tuple[str, str, str]] = [
29
+ (
30
+ "age_pattern", # 替换规则名称
31
+ r"(?:[1-9]|1[0-8])岁(?:的)?|(?:十一|十二|十三|十四|十五|十六|十七|十八|十|一|二|三|四|五|六|七|八|九)岁(?:的)?", # 正则模式
32
+ "", # 替换文本
33
+ ),
34
+ # 可在此处添加更多替换规则
35
+ # ("rule_name", r"pattern", "replacement"),
36
+ ]
37
+
38
+
39
+ def apply_regex_replacements(text: str) -> str:
40
+ """
41
+ 对文本应用正则替换规则
42
+
43
+ Args:
44
+ text: 要处理的文本
45
+
46
+ Returns:
47
+ 处理后的文本
48
+ """
49
+ if not text:
50
+ return text
51
+
52
+ processed_text = text
53
+ replacement_count = 0
54
+
55
+ for rule_name, pattern, replacement in REGEX_REPLACEMENTS:
56
+ try:
57
+ # 编译正则表达式,使用IGNORECASE标志
58
+ regex = re.compile(pattern, re.IGNORECASE)
59
+
60
+ # 执行替换
61
+ new_text, count = regex.subn(replacement, processed_text)
62
+
63
+ if count > 0:
64
+ log.debug(f"Regex replacement '{rule_name}': {count} matches replaced")
65
+ processed_text = new_text
66
+ replacement_count += count
67
+
68
+ except re.error as e:
69
+ log.error(f"Invalid regex pattern in rule '{rule_name}': {e}")
70
+ continue
71
+
72
+ if replacement_count > 0:
73
+ log.info(f"Applied {replacement_count} regex replacements to text")
74
+
75
+ return processed_text
76
+
77
+
78
+ def apply_regex_replacements_to_payload(payload: Dict[str, Any]) -> Dict[str, Any]:
79
+ """
80
+ 对请求payload中的文本内容应用正则替换
81
+
82
+ Args:
83
+ payload: 请求payload
84
+
85
+ Returns:
86
+ 应用替换后的payload
87
+ """
88
+ if not REGEX_REPLACEMENTS:
89
+ return payload
90
+
91
+ modified_payload = payload.copy()
92
+ request_data = modified_payload.get("request", {})
93
+
94
+ # 处理contents中的文本
95
+ contents = request_data.get("contents", [])
96
+ if contents:
97
+ new_contents = []
98
+ for content in contents:
99
+ if isinstance(content, dict):
100
+ new_content = content.copy()
101
+ parts = new_content.get("parts", [])
102
+ if parts:
103
+ new_parts = []
104
+ for part in parts:
105
+ if isinstance(part, dict) and "text" in part:
106
+ new_part = part.copy()
107
+ new_part["text"] = apply_regex_replacements(part["text"])
108
+ new_parts.append(new_part)
109
+ else:
110
+ new_parts.append(part)
111
+ new_content["parts"] = new_parts
112
+ new_contents.append(new_content)
113
+ else:
114
+ new_contents.append(content)
115
+
116
+ request_data["contents"] = new_contents
117
+ modified_payload["request"] = request_data
118
+ log.debug("Applied regex replacements to request contents")
119
+
120
+ return modified_payload
121
+
122
+
123
+ def apply_anti_truncation(payload: Dict[str, Any]) -> Dict[str, Any]:
124
+ """
125
+ 对请求payload应用反截断处理和正则替换
126
+ 在systemInstruction中添加提醒,要求模型在结束时输出DONE_MARKER标记
127
+
128
+ Args:
129
+ payload: 原始请求payload
130
+
131
+ Returns:
132
+ 添加了反截断指令并应用了正则替换的payload
133
+ """
134
+ # 首先应用正则替换
135
+ modified_payload = apply_regex_replacements_to_payload(payload)
136
+ request_data = modified_payload.get("request", {})
137
+
138
+ # 获取或创建systemInstruction
139
+ system_instruction = request_data.get("systemInstruction", {})
140
+ if not system_instruction:
141
+ system_instruction = {"parts": []}
142
+ elif "parts" not in system_instruction:
143
+ system_instruction["parts"] = []
144
+
145
+ # 添加反截断指令
146
+ anti_truncation_instruction = {
147
+ "text": f"""严格执行以下输出结束规则:
148
+
149
+ 1. 当你完成完整回答时,必须在输出的最后单独一行输出:{DONE_MARKER}
150
+ 2. {DONE_MARKER} 标记表示你的回答已经完全结束,这是必需的结束标记
151
+ 3. 只有输出了 {DONE_MARKER} 标记,系统才认为你的回答是完整的
152
+ 4. 如果你的回答被截断,系统会要求你继续输出剩余内容
153
+ 5. 无论回答长短,都必须以 {DONE_MARKER} 标记结束
154
+
155
+ 示例格式:
156
+ ```
157
+ 你的回答内容...
158
+ 更多回答内容...
159
+ {DONE_MARKER}
160
+ ```
161
+
162
+ 注意:{DONE_MARKER} 必须单独占一行,前面不要有任何其他字符。
163
+
164
+ 这个规则对于确保输出完整性极其重要,请严格遵守。"""
165
+ }
166
+
167
+ # 检查是否已经包含反截断指令
168
+ has_done_instruction = any(
169
+ part.get("text", "").find(DONE_MARKER) != -1
170
+ for part in system_instruction["parts"]
171
+ if isinstance(part, dict)
172
+ )
173
+
174
+ if not has_done_instruction:
175
+ system_instruction["parts"].append(anti_truncation_instruction)
176
+ request_data["systemInstruction"] = system_instruction
177
+ modified_payload["request"] = request_data
178
+
179
+ log.debug("Applied anti-truncation instruction to request")
180
+
181
+ return modified_payload
182
+
183
+
184
+ class AntiTruncationStreamProcessor:
185
+ """反截断流式处理器"""
186
+
187
+ def __init__(
188
+ self,
189
+ original_request_func,
190
+ payload: Dict[str, Any],
191
+ max_attempts: int = 3,
192
+ enable_prefill_mode: bool = False,
193
+ ):
194
+ self.original_request_func = original_request_func
195
+ self.base_payload = payload.copy()
196
+ self.max_attempts = max_attempts
197
+ self.enable_prefill_mode = enable_prefill_mode
198
+ # 使用 StringIO 避免字符串拼接的内存问题
199
+ self.collected_content = io.StringIO()
200
+ self.current_attempt = 0
201
+
202
+ def _get_collected_text(self) -> str:
203
+ """获取收集的文本内容"""
204
+ return self.collected_content.getvalue()
205
+
206
+ def _append_content(self, content: str):
207
+ """追加内容到收集器"""
208
+ if content:
209
+ self.collected_content.write(content)
210
+
211
+ def _clear_content(self):
212
+ """清空收集的内容,释放内存"""
213
+ self.collected_content.close()
214
+ self.collected_content = io.StringIO()
215
+
216
+ async def process_stream(self) -> AsyncGenerator[bytes, None]:
217
+ """处理流式响应,检测并处理截断"""
218
+
219
+ while self.current_attempt < self.max_attempts:
220
+ self.current_attempt += 1
221
+
222
+ # 构建当前请求payload
223
+ current_payload = self._build_current_payload()
224
+
225
+ log.debug(f"Anti-truncation attempt {self.current_attempt}/{self.max_attempts}")
226
+
227
+ # 发送请求
228
+ try:
229
+ response = await self.original_request_func(current_payload)
230
+
231
+ if not isinstance(response, StreamingResponse):
232
+ # 非流式响应,直接处理
233
+ yield await self._handle_non_streaming_response(response)
234
+ return
235
+
236
+ # 处理流式响应(按行处理)
237
+ chunk_buffer = io.StringIO() # 使用 StringIO 缓存当前轮次的chunk
238
+ found_done_marker = False
239
+
240
+ async for line in response.body_iterator:
241
+ if not line:
242
+ yield line
243
+ continue
244
+
245
+ # 处理上游生成器 yield 出 Response 对象的情况(错误响应)
246
+ from fastapi import Response as FastAPIResponse
247
+ if isinstance(line, FastAPIResponse):
248
+ log.error(f"Anti-truncation: Received Response object from stream (status={line.status_code}), treating as error")
249
+ error_chunk = {
250
+ "error": {
251
+ "message": line.body.decode('utf-8', errors='ignore') if hasattr(line, 'body') and line.body else "Upstream error",
252
+ "type": "api_error",
253
+ "code": line.status_code,
254
+ }
255
+ }
256
+ yield f"data: {json.dumps(error_chunk)}\n\n".encode()
257
+ yield b"data: [DONE]\n\n"
258
+ return
259
+
260
+ # 处理 bytes 类型的流式数据
261
+ if isinstance(line, bytes):
262
+ # 解码 bytes 为字符串
263
+ line_str = line.decode('utf-8', errors='ignore').strip()
264
+ else:
265
+ line_str = str(line).strip()
266
+
267
+ # 跳过空行
268
+ if not line_str:
269
+ yield line
270
+ continue
271
+
272
+ # 处理 SSE 格式的数据行
273
+ if line_str.startswith("data: "):
274
+ payload_str = line_str[6:] # 去掉 "data: " 前缀
275
+
276
+ # 检查是否是 [DONE] 标记
277
+ if payload_str.strip() == "[DONE]":
278
+ if found_done_marker:
279
+ log.info("Anti-truncation: Found [done] marker, output complete")
280
+ yield line
281
+ # 清理内存
282
+ chunk_buffer.close()
283
+ self._clear_content()
284
+ return
285
+ else:
286
+ log.warning("Anti-truncation: Stream ended without [done] marker")
287
+ # 不发送[DONE],准备继续
288
+ break
289
+
290
+ # 尝试解析 JSON 数据
291
+ try:
292
+ data = json.loads(payload_str)
293
+ content = self._extract_content_from_chunk(data)
294
+
295
+ log.debug(f"Anti-truncation: Extracted content: {repr(content[:100] if content else '')}")
296
+
297
+ if content:
298
+ chunk_buffer.write(content)
299
+
300
+ # 检查是否包含done标记
301
+ has_marker = self._check_done_marker_in_chunk_content(content)
302
+ log.debug(f"Anti-truncation: Check done marker result: {has_marker}, DONE_MARKER='{DONE_MARKER}'")
303
+ if has_marker:
304
+ found_done_marker = True
305
+ log.debug(f"Anti-truncation: Found [done] marker in chunk, content: {content[:200]}")
306
+
307
+ # 清理行中的[done]标记后再发送
308
+ cleaned_line = self._remove_done_marker_from_line(line, line_str, data)
309
+ yield cleaned_line
310
+
311
+ except (json.JSONDecodeError, ValueError):
312
+ # 无法解析的行,直接传递
313
+ yield line
314
+ continue
315
+ else:
316
+ # 非 data: 开头的行,直接传递
317
+ yield line
318
+
319
+ # 更新收集的内容 - 使用 StringIO 高效处理
320
+ chunk_text = chunk_buffer.getvalue()
321
+ if chunk_text:
322
+ self._append_content(chunk_text)
323
+ chunk_buffer.close()
324
+
325
+ log.debug(f"Anti-truncation: After processing stream, found_done_marker={found_done_marker}")
326
+
327
+ # 如果找到了done标记,结束
328
+ if found_done_marker:
329
+ # 立即清理内容释放内存
330
+ self._clear_content()
331
+ yield b"data: [DONE]\n\n"
332
+ return
333
+
334
+ # 只有在单个chunk中没有找到done标记时,才检查累积内容(防止done标记跨chunk出现)
335
+ if not found_done_marker:
336
+ accumulated_text = self._get_collected_text()
337
+ if self._check_done_marker_in_text(accumulated_text):
338
+ log.info("Anti-truncation: Found [done] marker in accumulated content")
339
+ # 立即清理内容释放内存
340
+ self._clear_content()
341
+ yield b"data: [DONE]\n\n"
342
+ return
343
+
344
+ # 如果没找到done标记且不是最后一次尝试,准备续传
345
+ if self.current_attempt < self.max_attempts:
346
+ accumulated_text = self._get_collected_text()
347
+ total_length = len(accumulated_text)
348
+ log.info(
349
+ f"Anti-truncation: No [done] marker found in output (length: {total_length}), preparing continuation (attempt {self.current_attempt + 1})"
350
+ )
351
+ if total_length > 100:
352
+ log.debug(
353
+ f"Anti-truncation: Current collected content ends with: ...{accumulated_text[-100:]}"
354
+ )
355
+ # 在下一次循环中会继续
356
+ continue
357
+ else:
358
+ # 最后一次尝试,直接结束
359
+ log.warning("Anti-truncation: Max attempts reached, ending stream")
360
+ # 立即清理内容释放内存
361
+ self._clear_content()
362
+ yield b"data: [DONE]\n\n"
363
+ return
364
+
365
+ except Exception as e:
366
+ log.error(f"Anti-truncation error in attempt {self.current_attempt}: {str(e)}")
367
+ if self.current_attempt >= self.max_attempts:
368
+ # 发送错误chunk
369
+ error_chunk = {
370
+ "error": {
371
+ "message": f"Anti-truncation failed: {str(e)}",
372
+ "type": "api_error",
373
+ "code": 500,
374
+ }
375
+ }
376
+ yield f"data: {json.dumps(error_chunk)}\n\n".encode()
377
+ yield b"data: [DONE]\n\n"
378
+ return
379
+ # 否则继续下一次尝试
380
+
381
+ # 如果所有尝试都失败了
382
+ log.error("Anti-truncation: All attempts failed")
383
+ # 清理内存
384
+ self._clear_content()
385
+ yield b"data: [DONE]\n\n"
386
+
387
+ def _build_current_payload(self) -> Dict[str, Any]:
388
+ """构建当前请求的payload"""
389
+ if self.current_attempt == 1:
390
+ # 第一次请求,使用原始payload(已经包含反截断指令)
391
+ return self.base_payload
392
+
393
+ # 后续请求,添加续传指令
394
+ continuation_payload = self.base_payload.copy()
395
+ request_data = continuation_payload.get("request", {})
396
+
397
+ # 获取原始对话内容
398
+ contents = request_data.get("contents", [])
399
+ new_contents = contents.copy()
400
+
401
+ # 如果有收集到的内容,添加到对话中
402
+ accumulated_text = self._get_collected_text()
403
+ if accumulated_text:
404
+ new_contents.append({"role": "model", "parts": [{"text": accumulated_text}]})
405
+
406
+ # 预填充模式:直接用拼接内容作为末尾 model 预填充,不再增加 user 续写指令
407
+ if self.enable_prefill_mode:
408
+ log.debug("Anti-truncation: Using prefill continuation mode (no user continuation prompt)")
409
+ request_data["contents"] = new_contents
410
+ continuation_payload["request"] = request_data
411
+ return continuation_payload
412
+
413
+ # 构建具体的续写指令,包含前面的内容摘要
414
+ content_summary = ""
415
+ if accumulated_text:
416
+ if len(accumulated_text) > 200:
417
+ content_summary = f'\n\n前面你已经输出了约 {len(accumulated_text)} 个字符的内容,结尾是:\n"...{accumulated_text[-100:]}"'
418
+ else:
419
+ content_summary = f'\n\n前面你已经输出的内容是:\n"{accumulated_text}"'
420
+
421
+ detailed_continuation_prompt = f"""{CONTINUATION_PROMPT}{content_summary}"""
422
+
423
+ # 添加继续指令
424
+ continuation_message = {"role": "user", "parts": [{"text": detailed_continuation_prompt}]}
425
+ new_contents.append(continuation_message)
426
+
427
+ request_data["contents"] = new_contents
428
+ continuation_payload["request"] = request_data
429
+
430
+ return continuation_payload
431
+
432
+ def _extract_content_from_chunk(self, data: Dict[str, Any]) -> str:
433
+ """从chunk数据中提取文本内容"""
434
+ content = ""
435
+
436
+ # 先尝试解包 response 字段(Gemini API 格式)
437
+ if "response" in data:
438
+ data = data["response"]
439
+
440
+ # 处理 Gemini 格式
441
+ if "candidates" in data:
442
+ for candidate in data["candidates"]:
443
+ if "content" in candidate:
444
+ parts = candidate["content"].get("parts", [])
445
+ for part in parts:
446
+ if "text" in part:
447
+ content += part["text"]
448
+
449
+ # 处理 OpenAI 流式格式(choices/delta)
450
+ elif "choices" in data:
451
+ for choice in data["choices"]:
452
+ if "delta" in choice and "content" in choice["delta"]:
453
+ delta_content = choice["delta"]["content"]
454
+ if delta_content:
455
+ content += delta_content
456
+
457
+ return content
458
+
459
+ async def _handle_non_streaming_response(self, response) -> bytes:
460
+ """处理非流式响应 - 使用循环代替递归避免栈溢出"""
461
+ # 使用循环代替递归
462
+ while True:
463
+ try:
464
+ # 特殊处理:如果返回的是StreamingResponse,需要读取其body_iterator
465
+ if isinstance(response, StreamingResponse):
466
+ log.error("Anti-truncation: Received StreamingResponse in non-streaming handler - this should not happen")
467
+ # 尝试读取流式响应的内容
468
+ chunks = []
469
+ async for chunk in response.body_iterator:
470
+ chunks.append(chunk)
471
+ content = b"".join(chunks).decode() if chunks else ""
472
+ # 提取响应内容
473
+ elif hasattr(response, "body"):
474
+ content = (
475
+ response.body.decode() if isinstance(response.body, bytes) else response.body
476
+ )
477
+ elif hasattr(response, "content"):
478
+ content = (
479
+ response.content.decode()
480
+ if isinstance(response.content, bytes)
481
+ else response.content
482
+ )
483
+ else:
484
+ log.error(f"Anti-truncation: Unknown response type: {type(response)}")
485
+ content = str(response)
486
+
487
+ # 验证内容不为空
488
+ if not content or not content.strip():
489
+ log.error("Anti-truncation: Received empty response content")
490
+ return json.dumps(
491
+ {
492
+ "error": {
493
+ "message": "Empty response from server",
494
+ "type": "api_error",
495
+ "code": 500,
496
+ }
497
+ }
498
+ ).encode()
499
+
500
+ # 尝试解析 JSON
501
+ try:
502
+ response_data = json.loads(content)
503
+ except json.JSONDecodeError as json_err:
504
+ log.error(f"Anti-truncation: Failed to parse JSON response: {json_err}, content: {content[:200]}")
505
+ # 如果不是 JSON,直接返回原始内容
506
+ return content.encode() if isinstance(content, str) else content
507
+
508
+ # 检查是否包含done标记
509
+ text_content = self._extract_content_from_response(response_data)
510
+ has_done_marker = self._check_done_marker_in_text(text_content)
511
+
512
+ if has_done_marker or self.current_attempt >= self.max_attempts:
513
+ # 找到done标记或达到最大尝试次数,返回结果
514
+ return content.encode() if isinstance(content, str) else content
515
+
516
+ # 需要继续,收集内容并构建下一个请求
517
+ if text_content:
518
+ self._append_content(text_content)
519
+
520
+ log.info("Anti-truncation: Non-streaming response needs continuation")
521
+
522
+ # 增加尝试次数
523
+ self.current_attempt += 1
524
+
525
+ # 构建续传payload并发送下一个请求
526
+ next_payload = self._build_current_payload()
527
+ response = await self.original_request_func(next_payload)
528
+
529
+ # 继续循环处理下一个响应
530
+
531
+ except Exception as e:
532
+ log.error(f"Anti-truncation non-streaming error: {str(e)}")
533
+ return json.dumps(
534
+ {
535
+ "error": {
536
+ "message": f"Anti-truncation failed: {str(e)}",
537
+ "type": "api_error",
538
+ "code": 500,
539
+ }
540
+ }
541
+ ).encode()
542
+
543
+ def _check_done_marker_in_text(self, text: str) -> bool:
544
+ """检测文本中是否包含DONE_MARKER(只检测指定标记)"""
545
+ if not text:
546
+ return False
547
+
548
+ # 只要文本中出现DONE_MARKER即可
549
+ return DONE_MARKER in text
550
+
551
+ def _check_done_marker_in_chunk_content(self, content: str) -> bool:
552
+ """检查单个chunk内容中是否包含done标记"""
553
+ return self._check_done_marker_in_text(content)
554
+
555
+ def _extract_content_from_response(self, data: Dict[str, Any]) -> str:
556
+ """从响应数据中提取文本内容"""
557
+ content = ""
558
+
559
+ # 先尝试解包 response 字段(Gemini API 格式)
560
+ if "response" in data:
561
+ data = data["response"]
562
+
563
+ # 处理Gemini格式
564
+ if "candidates" in data:
565
+ for candidate in data["candidates"]:
566
+ if "content" in candidate:
567
+ parts = candidate["content"].get("parts", [])
568
+ for part in parts:
569
+ if "text" in part:
570
+ content += part["text"]
571
+
572
+ # 处理OpenAI格式
573
+ elif "choices" in data:
574
+ for choice in data["choices"]:
575
+ if "message" in choice and "content" in choice["message"]:
576
+ content += choice["message"]["content"]
577
+
578
+ return content
579
+
580
+ def _remove_done_marker_from_line(self, line: bytes, line_str: str, data: Dict[str, Any]) -> bytes:
581
+ """从行中移除[done]标记"""
582
+ try:
583
+ # 首先检查是否真的包含[done]标记
584
+ if "[done]" not in line_str.lower():
585
+ return line # 没有[done]标记,直接返回原始行
586
+
587
+ log.info(f"Anti-truncation: Attempting to remove [done] marker from line")
588
+ log.debug(f"Anti-truncation: Original line (first 200 chars): {line_str[:200]}")
589
+
590
+ # 编译正则表达式,匹配[done]标记(忽略大小写,包括可能的空白字符)
591
+ done_pattern = re.compile(r"\s*\[done\]\s*", re.IGNORECASE)
592
+
593
+ # 检查是否有 response 包裹层
594
+ has_response_wrapper = "response" in data
595
+ log.debug(f"Anti-truncation: has_response_wrapper={has_response_wrapper}, data keys={list(data.keys())}")
596
+ if has_response_wrapper:
597
+ # 需要保留外层的 response 字段
598
+ inner_data = data["response"]
599
+ else:
600
+ inner_data = data
601
+
602
+ log.debug(f"Anti-truncation: inner_data keys={list(inner_data.keys())}")
603
+
604
+ log.debug(f"Anti-truncation: inner_data keys={list(inner_data.keys())}")
605
+
606
+ # 处理Gemini格式
607
+ if "candidates" in inner_data:
608
+ log.info(f"Anti-truncation: Processing Gemini format to remove [done] marker")
609
+ modified_inner = inner_data.copy()
610
+ modified_inner["candidates"] = []
611
+
612
+ for i, candidate in enumerate(inner_data["candidates"]):
613
+ modified_candidate = candidate.copy()
614
+ # 只在最后一个candidate中清理[done]标记
615
+ is_last_candidate = i == len(inner_data["candidates"]) - 1
616
+
617
+ if "content" in candidate:
618
+ modified_content = candidate["content"].copy()
619
+ if "parts" in modified_content:
620
+ modified_parts = []
621
+ for part in modified_content["parts"]:
622
+ if "text" in part and isinstance(part["text"], str):
623
+ modified_part = part.copy()
624
+ original_text = part["text"]
625
+ # 只在最后一个candidate中清理[done]标记
626
+ if is_last_candidate:
627
+ modified_part["text"] = done_pattern.sub("", part["text"])
628
+ if "[done]" in original_text.lower():
629
+ log.debug(f"Anti-truncation: Removed [done] from text: '{original_text[:100]}' -> '{modified_part['text'][:100]}'")
630
+ modified_parts.append(modified_part)
631
+ else:
632
+ modified_parts.append(part)
633
+ modified_content["parts"] = modified_parts
634
+ modified_candidate["content"] = modified_content
635
+ modified_inner["candidates"].append(modified_candidate)
636
+
637
+ # 如果有 response 包裹层,需要重新包装
638
+ if has_response_wrapper:
639
+ modified_data = data.copy()
640
+ modified_data["response"] = modified_inner
641
+ else:
642
+ modified_data = modified_inner
643
+
644
+ # 重新编码为行格式 - SSE格式需要两个换行符
645
+ json_str = json.dumps(modified_data, separators=(",", ":"), ensure_ascii=False)
646
+ result = f"data: {json_str}\n\n".encode("utf-8")
647
+ log.debug(f"Anti-truncation: Modified line (first 200 chars): {result.decode('utf-8', errors='ignore')[:200]}")
648
+ return result
649
+
650
+ # 处理OpenAI格式
651
+ elif "choices" in inner_data:
652
+ modified_inner = inner_data.copy()
653
+ modified_inner["choices"] = []
654
+
655
+ for choice in inner_data["choices"]:
656
+ modified_choice = choice.copy()
657
+ if "delta" in choice and "content" in choice["delta"]:
658
+ modified_delta = choice["delta"].copy()
659
+ modified_delta["content"] = done_pattern.sub("", choice["delta"]["content"])
660
+ modified_choice["delta"] = modified_delta
661
+ elif "message" in choice and "content" in choice["message"]:
662
+ modified_message = choice["message"].copy()
663
+ modified_message["content"] = done_pattern.sub("", choice["message"]["content"])
664
+ modified_choice["message"] = modified_message
665
+ modified_inner["choices"].append(modified_choice)
666
+
667
+ # 如果有 response 包裹层,需要重新包装
668
+ if has_response_wrapper:
669
+ modified_data = data.copy()
670
+ modified_data["response"] = modified_inner
671
+ else:
672
+ modified_data = modified_inner
673
+
674
+ # 重新编码为行格式 - SSE格式需要两个换行符
675
+ json_str = json.dumps(modified_data, separators=(",", ":"), ensure_ascii=False)
676
+ return f"data: {json_str}\n\n".encode("utf-8")
677
+
678
+ # 如果没有找到支持的格式,返回原始行
679
+ return line
680
+
681
+ except Exception as e:
682
+ log.warning(f"Failed to remove [done] marker from line: {str(e)}")
683
+ return line
684
+
685
+
686
+ async def apply_anti_truncation_to_stream(
687
+ request_func,
688
+ payload: Dict[str, Any],
689
+ max_attempts: int = 3,
690
+ enable_prefill_mode: bool = False,
691
+ ) -> StreamingResponse:
692
+ """
693
+ 对流式请求应用反截断处理
694
+
695
+ Args:
696
+ request_func: 原始请求函数
697
+ payload: 请求payload
698
+ max_attempts: 最大续传尝试次数
699
+ enable_prefill_mode: 是否启用预填充模式。启用后续传请求不再添加 user 续写指令,
700
+ 而是将已收集内容作为末尾 model 内容进行预填充
701
+
702
+ Returns:
703
+ 处理后的StreamingResponse
704
+ """
705
+
706
+ # 首先对payload应用反截断指令
707
+ anti_truncation_payload = apply_anti_truncation(payload)
708
+
709
+ # 创建反截断处理器
710
+ processor = AntiTruncationStreamProcessor(
711
+ lambda p: request_func(p),
712
+ anti_truncation_payload,
713
+ max_attempts,
714
+ enable_prefill_mode,
715
+ )
716
+
717
+ # 返回包装后的流式响应
718
+ return StreamingResponse(processor.process_stream(), media_type="text/event-stream")
719
+
720
+
721
+ def is_anti_truncation_enabled(request_data: Dict[str, Any]) -> bool:
722
+ """
723
+ 检查请求是否启用了反截断功能
724
+
725
+ Args:
726
+ request_data: 请求数据
727
+
728
+ Returns:
729
+ 是否启用反截断
730
+ """
731
+ return request_data.get("enable_anti_truncation", False)
src/converter/fake_stream.py ADDED
@@ -0,0 +1,537 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Any, Dict, List, Tuple
2
+ import json
3
+ from src.converter.utils import extract_content_and_reasoning
4
+ from log import log
5
+ from src.converter.openai2gemini import _convert_usage_metadata
6
+
7
+ def safe_get_nested(obj: Any, *keys: str, default: Any = None) -> Any:
8
+ """安全获取嵌套字典值
9
+
10
+ Args:
11
+ obj: 字典对象
12
+ *keys: 嵌套键路径
13
+ default: 默认值
14
+
15
+ Returns:
16
+ 获取到的值或默认值
17
+ """
18
+ for key in keys:
19
+ if not isinstance(obj, dict):
20
+ return default
21
+ obj = obj.get(key, default)
22
+ if obj is default:
23
+ return default
24
+ return obj
25
+
26
+ def parse_response_for_fake_stream(response_data: Dict[str, Any]) -> tuple:
27
+ """从完整响应中提取内容和推理内容(用于假流式)
28
+
29
+ Args:
30
+ response_data: Gemini API 响应数据
31
+
32
+ Returns:
33
+ (content, reasoning_content, finish_reason, images): 内容、推理内容、结束原因和图片数据的元组
34
+ """
35
+ import json
36
+
37
+ # 处理GeminiCLI的response包装格式
38
+ if "response" in response_data and "candidates" not in response_data:
39
+ log.debug(f"[FAKE_STREAM] Unwrapping response field")
40
+ response_data = response_data["response"]
41
+
42
+ candidates = response_data.get("candidates", [])
43
+ log.debug(f"[FAKE_STREAM] Found {len(candidates)} candidates")
44
+ if not candidates:
45
+ return "", "", "STOP", []
46
+
47
+ candidate = candidates[0]
48
+ finish_reason = candidate.get("finishReason", "STOP")
49
+ parts = safe_get_nested(candidate, "content", "parts", default=[])
50
+ log.debug(f"[FAKE_STREAM] Extracted {len(parts)} parts: {json.dumps(parts, ensure_ascii=False)}")
51
+ content, reasoning_content, images = extract_content_and_reasoning(parts)
52
+ log.debug(f"[FAKE_STREAM] Content length: {len(content)}, Reasoning length: {len(reasoning_content)}, Images count: {len(images)}")
53
+
54
+ return content, reasoning_content, finish_reason, images
55
+
56
+ def extract_fake_stream_content(response: Any) -> Tuple[str, str, Dict[str, int]]:
57
+ """
58
+ 从 Gemini 非流式响应中提取内容,用于假流式处理
59
+
60
+ Args:
61
+ response: Gemini API 响应对象
62
+
63
+ Returns:
64
+ (content, reasoning_content, usage) 元组
65
+ """
66
+ from src.converter.utils import extract_content_and_reasoning
67
+
68
+ # 解析响应体
69
+ if hasattr(response, "body"):
70
+ body_str = (
71
+ response.body.decode()
72
+ if isinstance(response.body, bytes)
73
+ else str(response.body)
74
+ )
75
+ elif hasattr(response, "content"):
76
+ body_str = (
77
+ response.content.decode()
78
+ if isinstance(response.content, bytes)
79
+ else str(response.content)
80
+ )
81
+ else:
82
+ body_str = str(response)
83
+
84
+ try:
85
+ response_data = json.loads(body_str)
86
+
87
+ # GeminiCLI 返回的格式是 {"response": {...}, "traceId": "..."}
88
+ # 需要先提取 response 字段
89
+ if "response" in response_data:
90
+ gemini_response = response_data["response"]
91
+ else:
92
+ gemini_response = response_data
93
+
94
+ # 从Gemini响应中提取内容,使用思维链分离逻辑
95
+ content = ""
96
+ reasoning_content = ""
97
+ images = []
98
+ if "candidates" in gemini_response and gemini_response["candidates"]:
99
+ # Gemini格式响应 - 使用思维链分离
100
+ candidate = gemini_response["candidates"][0]
101
+ if "content" in candidate and "parts" in candidate["content"]:
102
+ parts = candidate["content"]["parts"]
103
+ content, reasoning_content, images = extract_content_and_reasoning(parts)
104
+ elif "choices" in gemini_response and gemini_response["choices"]:
105
+ # OpenAI格式响应
106
+ content = gemini_response["choices"][0].get("message", {}).get("content", "")
107
+
108
+ # 如果没有正常内容但有思维内容,给出警告
109
+ if not content and reasoning_content:
110
+ log.warning("Fake stream response contains only thinking content")
111
+ content = "[模型正在思考中,请稍后再试或重新提问]"
112
+
113
+ # 如果完全没有内容,提供默认回复
114
+ if not content:
115
+ log.warning(f"No content found in response: {gemini_response}")
116
+ content = "[响应为空,请重新尝试]"
117
+
118
+ # 转换usageMetadata为OpenAI格式
119
+ usage = _convert_usage_metadata(gemini_response.get("usageMetadata"))
120
+
121
+ return content, reasoning_content, usage
122
+
123
+ except json.JSONDecodeError:
124
+ # 如果不是JSON,直接返回原始文本
125
+ return body_str, "", None
126
+
127
+ def _build_candidate(parts: List[Dict[str, Any]], finish_reason: str = "STOP") -> Dict[str, Any]:
128
+ """构建标准候选响应结构
129
+
130
+ Args:
131
+ parts: parts 列表
132
+ finish_reason: 结束原因
133
+
134
+ Returns:
135
+ 候选响应字典
136
+ """
137
+ return {
138
+ "candidates": [{
139
+ "content": {"parts": parts, "role": "model"},
140
+ "finishReason": finish_reason,
141
+ "index": 0,
142
+ }]
143
+ }
144
+
145
+ def create_openai_heartbeat_chunk() -> Dict[str, Any]:
146
+ """
147
+ 创建 OpenAI 格式的心跳块(用于假流式)
148
+
149
+ Returns:
150
+ 心跳响应块字典
151
+ """
152
+ return {
153
+ "choices": [
154
+ {
155
+ "index": 0,
156
+ "delta": {"role": "assistant", "content": ""},
157
+ "finish_reason": None,
158
+ }
159
+ ]
160
+ }
161
+
162
+ def build_gemini_fake_stream_chunks(content: str, reasoning_content: str, finish_reason: str, images: List[Dict[str, Any]] = None, chunk_size: int = 50) -> List[Dict[str, Any]]:
163
+ """构建假流式响应的数据块
164
+
165
+ Args:
166
+ content: 主要内容
167
+ reasoning_content: 推理内容
168
+ finish_reason: 结束原因
169
+ images: 图片数据列表(可选)
170
+ chunk_size: 每个chunk的字符数(默认50)
171
+
172
+ Returns:
173
+ 响应数据块列表
174
+ """
175
+ if images is None:
176
+ images = []
177
+
178
+ log.debug(f"[build_gemini_fake_stream_chunks] Input - content: {repr(content)}, reasoning: {repr(reasoning_content)}, finish_reason: {finish_reason}, images count: {len(images)}")
179
+ chunks = []
180
+
181
+ # 如果没有正常内容但有思维内容,提供默认回复
182
+ if not content:
183
+ default_text = "[模型正在思考中,请稍后再试或重新提问]" if reasoning_content else "[响应为空,请重新尝试]"
184
+ return [_build_candidate([{"text": default_text}], finish_reason)]
185
+
186
+ # 分块发送主要内容
187
+ first_chunk = True
188
+ for i in range(0, len(content), chunk_size):
189
+ chunk_text = content[i:i + chunk_size]
190
+ is_last_chunk = (i + chunk_size >= len(content)) and not reasoning_content
191
+ chunk_finish_reason = finish_reason if is_last_chunk else None
192
+
193
+ # 如果是第一个chunk且有图片,将图片包含在parts中
194
+ parts = []
195
+ if first_chunk and images:
196
+ # 在Gemini格式中,需要将image_url格式转换为inlineData格式
197
+ for img in images:
198
+ if img.get("type") == "image_url":
199
+ url = img.get("image_url", {}).get("url", "")
200
+ # 解析 data URL: data:{mime_type};base64,{data}
201
+ if url.startswith("data:"):
202
+ parts_of_url = url.split(";base64,")
203
+ if len(parts_of_url) == 2:
204
+ mime_type = parts_of_url[0].replace("data:", "")
205
+ base64_data = parts_of_url[1]
206
+ parts.append({
207
+ "inlineData": {
208
+ "mimeType": mime_type,
209
+ "data": base64_data
210
+ }
211
+ })
212
+ first_chunk = False
213
+
214
+ parts.append({"text": chunk_text})
215
+ chunk_data = _build_candidate(parts, chunk_finish_reason)
216
+ log.debug(f"[build_gemini_fake_stream_chunks] Generated chunk: {chunk_data}")
217
+ chunks.append(chunk_data)
218
+
219
+ # 如果有推理内容,分块发送
220
+ if reasoning_content:
221
+ for i in range(0, len(reasoning_content), chunk_size):
222
+ chunk_text = reasoning_content[i:i + chunk_size]
223
+ is_last_chunk = i + chunk_size >= len(reasoning_content)
224
+ chunk_finish_reason = finish_reason if is_last_chunk else None
225
+ chunks.append(_build_candidate([{"text": chunk_text, "thought": True}], chunk_finish_reason))
226
+
227
+ log.debug(f"[build_gemini_fake_stream_chunks] Total chunks generated: {len(chunks)}")
228
+ return chunks
229
+
230
+
231
+ def create_gemini_heartbeat_chunk() -> Dict[str, Any]:
232
+ """创建 Gemini 格式的心跳数据块
233
+
234
+ Returns:
235
+ 心跳数据块
236
+ """
237
+ chunk = _build_candidate([{"text": ""}])
238
+ chunk["candidates"][0]["finishReason"] = None
239
+ return chunk
240
+
241
+
242
+ def build_openai_fake_stream_chunks(content: str, reasoning_content: str, finish_reason: str, model: str, images: List[Dict[str, Any]] = None, chunk_size: int = 50) -> List[Dict[str, Any]]:
243
+ """构建 OpenAI 格式的假流式响应数据块
244
+
245
+ Args:
246
+ content: 主要内容
247
+ reasoning_content: 推理内容
248
+ finish_reason: 结束原因(如 "STOP", "MAX_TOKENS")
249
+ model: 模型名称
250
+ images: 图片数据列表(可选)
251
+ chunk_size: 每个chunk的字符数(默认50)
252
+
253
+ Returns:
254
+ OpenAI 格式的响应数据块列表
255
+ """
256
+ import time
257
+ import uuid
258
+
259
+ if images is None:
260
+ images = []
261
+
262
+ log.debug(f"[build_openai_fake_stream_chunks] Input - content: {repr(content)}, reasoning: {repr(reasoning_content)}, finish_reason: {finish_reason}, images count: {len(images)}")
263
+ chunks = []
264
+ response_id = f"chatcmpl-{uuid.uuid4().hex[:24]}"
265
+ created = int(time.time())
266
+
267
+ # 映射 Gemini finish_reason 到 OpenAI 格式
268
+ openai_finish_reason = None
269
+ if finish_reason == "STOP":
270
+ openai_finish_reason = "stop"
271
+ elif finish_reason == "MAX_TOKENS":
272
+ openai_finish_reason = "length"
273
+ elif finish_reason in ["SAFETY", "RECITATION"]:
274
+ openai_finish_reason = "content_filter"
275
+
276
+ # 如果没有正常内容但有思维内容,提供默认回复
277
+ if not content:
278
+ default_text = "[模型正在思考中,请稍后再试或重新提问]" if reasoning_content else "[响应为空,请重新尝试]"
279
+ return [{
280
+ "id": response_id,
281
+ "object": "chat.completion.chunk",
282
+ "created": created,
283
+ "model": model,
284
+ "choices": [{
285
+ "index": 0,
286
+ "delta": {"content": default_text},
287
+ "finish_reason": openai_finish_reason,
288
+ }]
289
+ }]
290
+
291
+ # 分块发送主要内容
292
+ first_chunk = True
293
+ for i in range(0, len(content), chunk_size):
294
+ chunk_text = content[i:i + chunk_size]
295
+ is_last_chunk = (i + chunk_size >= len(content)) and not reasoning_content
296
+ chunk_finish = openai_finish_reason if is_last_chunk else None
297
+
298
+ delta_content = {}
299
+
300
+ # 如果是第一个chunk且有图片,构建包含图片的content数组
301
+ if first_chunk and images:
302
+ delta_content["content"] = images + [{"type": "text", "text": chunk_text}]
303
+ first_chunk = False
304
+ else:
305
+ delta_content["content"] = chunk_text
306
+
307
+ chunk_data = {
308
+ "id": response_id,
309
+ "object": "chat.completion.chunk",
310
+ "created": created,
311
+ "model": model,
312
+ "choices": [{
313
+ "index": 0,
314
+ "delta": delta_content,
315
+ "finish_reason": chunk_finish,
316
+ }]
317
+ }
318
+ log.debug(f"[build_openai_fake_stream_chunks] Generated chunk: {chunk_data}")
319
+ chunks.append(chunk_data)
320
+
321
+ # 如果有推理内容,分块发送(使用 reasoning_content 字段)
322
+ if reasoning_content:
323
+ for i in range(0, len(reasoning_content), chunk_size):
324
+ chunk_text = reasoning_content[i:i + chunk_size]
325
+ is_last_chunk = i + chunk_size >= len(reasoning_content)
326
+ chunk_finish = openai_finish_reason if is_last_chunk else None
327
+
328
+ chunks.append({
329
+ "id": response_id,
330
+ "object": "chat.completion.chunk",
331
+ "created": created,
332
+ "model": model,
333
+ "choices": [{
334
+ "index": 0,
335
+ "delta": {"reasoning_content": chunk_text},
336
+ "finish_reason": chunk_finish,
337
+ }]
338
+ })
339
+
340
+ log.debug(f"[build_openai_fake_stream_chunks] Total chunks generated: {len(chunks)}")
341
+ return chunks
342
+
343
+
344
+ def create_anthropic_heartbeat_chunk() -> Dict[str, Any]:
345
+ """
346
+ 创建 Anthropic 格式的心跳块(用于假流式)
347
+
348
+ Returns:
349
+ 心跳响应块字典
350
+ """
351
+ return {
352
+ "type": "ping"
353
+ }
354
+
355
+
356
+ def build_anthropic_fake_stream_chunks(content: str, reasoning_content: str, finish_reason: str, model: str, images: List[Dict[str, Any]] = None, chunk_size: int = 50) -> List[Dict[str, Any]]:
357
+ """构建 Anthropic 格式的假流式响应数据块
358
+
359
+ Args:
360
+ content: 主要内容
361
+ reasoning_content: 推理内容(thinking content)
362
+ finish_reason: 结束原因(如 "STOP", "MAX_TOKENS")
363
+ model: 模型名称
364
+ images: 图片数据列表(可选)
365
+ chunk_size: 每个chunk的字符数(默认50)
366
+
367
+ Returns:
368
+ Anthropic SSE 格式的响应数据块列表
369
+ """
370
+ import uuid
371
+
372
+ if images is None:
373
+ images = []
374
+
375
+ log.debug(f"[build_anthropic_fake_stream_chunks] Input - content: {repr(content)}, reasoning: {repr(reasoning_content)}, finish_reason: {finish_reason}, images count: {len(images)}")
376
+ chunks = []
377
+ message_id = f"msg_{uuid.uuid4().hex}"
378
+
379
+ # 映射 Gemini finish_reason 到 Anthropic 格式
380
+ anthropic_stop_reason = "end_turn"
381
+ if finish_reason == "MAX_TOKENS":
382
+ anthropic_stop_reason = "max_tokens"
383
+ elif finish_reason in ["SAFETY", "RECITATION"]:
384
+ anthropic_stop_reason = "end_turn"
385
+
386
+ # 1. 发送 message_start 事件
387
+ chunks.append({
388
+ "type": "message_start",
389
+ "message": {
390
+ "id": message_id,
391
+ "type": "message",
392
+ "role": "assistant",
393
+ "model": model,
394
+ "content": [],
395
+ "stop_reason": None,
396
+ "stop_sequence": None,
397
+ "usage": {"input_tokens": 0, "output_tokens": 0}
398
+ }
399
+ })
400
+
401
+ # 如果没有正常内容但有思维内容,提供默认回复
402
+ if not content:
403
+ default_text = "[模型正在思考中,请稍后再试或重新提问]" if reasoning_content else "[响应为空,请重新尝试]"
404
+
405
+ # content_block_start
406
+ chunks.append({
407
+ "type": "content_block_start",
408
+ "index": 0,
409
+ "content_block": {"type": "text", "text": ""}
410
+ })
411
+
412
+ # content_block_delta
413
+ chunks.append({
414
+ "type": "content_block_delta",
415
+ "index": 0,
416
+ "delta": {"type": "text_delta", "text": default_text}
417
+ })
418
+
419
+ # content_block_stop
420
+ chunks.append({
421
+ "type": "content_block_stop",
422
+ "index": 0
423
+ })
424
+
425
+ # message_delta
426
+ chunks.append({
427
+ "type": "message_delta",
428
+ "delta": {"stop_reason": anthropic_stop_reason, "stop_sequence": None},
429
+ "usage": {"output_tokens": 0}
430
+ })
431
+
432
+ # message_stop
433
+ chunks.append({
434
+ "type": "message_stop"
435
+ })
436
+
437
+ return chunks
438
+
439
+ block_index = 0
440
+
441
+ # 2. 如果有推理内容,先发送 thinking 块
442
+ if reasoning_content:
443
+ # thinking content_block_start
444
+ chunks.append({
445
+ "type": "content_block_start",
446
+ "index": block_index,
447
+ "content_block": {"type": "thinking", "thinking": ""}
448
+ })
449
+
450
+ # 分块发送推理内容
451
+ for i in range(0, len(reasoning_content), chunk_size):
452
+ chunk_text = reasoning_content[i:i + chunk_size]
453
+ chunks.append({
454
+ "type": "content_block_delta",
455
+ "index": block_index,
456
+ "delta": {"type": "thinking_delta", "thinking": chunk_text}
457
+ })
458
+
459
+ # thinking content_block_stop
460
+ chunks.append({
461
+ "type": "content_block_stop",
462
+ "index": block_index
463
+ })
464
+
465
+ block_index += 1
466
+
467
+ # 3. 如果有图片,发送图片块
468
+ if images:
469
+ for img in images:
470
+ if img.get("type") == "image_url":
471
+ url = img.get("image_url", {}).get("url", "")
472
+ # 解析 data URL: data:{mime_type};base64,{data}
473
+ if url.startswith("data:"):
474
+ parts_of_url = url.split(";base64,")
475
+ if len(parts_of_url) == 2:
476
+ mime_type = parts_of_url[0].replace("data:", "")
477
+ base64_data = parts_of_url[1]
478
+
479
+ # image content_block_start
480
+ chunks.append({
481
+ "type": "content_block_start",
482
+ "index": block_index,
483
+ "content_block": {
484
+ "type": "image",
485
+ "source": {
486
+ "type": "base64",
487
+ "media_type": mime_type,
488
+ "data": base64_data
489
+ }
490
+ }
491
+ })
492
+
493
+ # image content_block_stop
494
+ chunks.append({
495
+ "type": "content_block_stop",
496
+ "index": block_index
497
+ })
498
+
499
+ block_index += 1
500
+
501
+ # 4. 发送主要内容(text 块)
502
+ # text content_block_start
503
+ chunks.append({
504
+ "type": "content_block_start",
505
+ "index": block_index,
506
+ "content_block": {"type": "text", "text": ""}
507
+ })
508
+
509
+ # 分块发送主要内容
510
+ for i in range(0, len(content), chunk_size):
511
+ chunk_text = content[i:i + chunk_size]
512
+ chunks.append({
513
+ "type": "content_block_delta",
514
+ "index": block_index,
515
+ "delta": {"type": "text_delta", "text": chunk_text}
516
+ })
517
+
518
+ # text content_block_stop
519
+ chunks.append({
520
+ "type": "content_block_stop",
521
+ "index": block_index
522
+ })
523
+
524
+ # 5. 发送 message_delta
525
+ chunks.append({
526
+ "type": "message_delta",
527
+ "delta": {"stop_reason": anthropic_stop_reason, "stop_sequence": None},
528
+ "usage": {"output_tokens": len(content) + len(reasoning_content)}
529
+ })
530
+
531
+ # 6. 发送 message_stop
532
+ chunks.append({
533
+ "type": "message_stop"
534
+ })
535
+
536
+ log.debug(f"[build_anthropic_fake_stream_chunks] Total chunks generated: {len(chunks)}")
537
+ return chunks
src/converter/gemini_fix.py ADDED
@@ -0,0 +1,859 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Gemini Format Utilities - 统一的 Gemini 格式处理和转换工具
3
+ 提供对 Gemini API 请求体和响应的标准化处理
4
+ ────────────────────────────────────────────────────────────────
5
+ """
6
+ import json
7
+ from math import e
8
+ from typing import Any, Dict, Optional
9
+
10
+ from log import log
11
+ from src.converter.thoughtSignature_fix import SKIP_THOUGHT_SIGNATURE_VALIDATOR
12
+
13
+ # ==================== Gemini API 配置 ====================
14
+
15
+ # ====================== Model Configuration ======================
16
+
17
+ # Default Safety Settings for Google API
18
+ DEFAULT_SAFETY_SETTINGS = [
19
+ {"category": "HARM_CATEGORY_HARASSMENT", "threshold": "BLOCK_NONE"},
20
+ {"category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "BLOCK_NONE"},
21
+ {"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", "threshold": "BLOCK_NONE"},
22
+ {"category": "HARM_CATEGORY_DANGEROUS_CONTENT", "threshold": "BLOCK_NONE"},
23
+ {"category": "HARM_CATEGORY_CIVIC_INTEGRITY", "threshold": "BLOCK_NONE"},
24
+ ]
25
+
26
+ LITE_SAFETY_SETTINGS = [
27
+ {"category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "BLOCK_NONE"},
28
+ {"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", "threshold": "BLOCK_NONE"},
29
+ {"category": "HARM_CATEGORY_DANGEROUS_CONTENT", "threshold": "BLOCK_NONE"},
30
+ {"category": "HARM_CATEGORY_HARASSMENT", "threshold": "BLOCK_NONE"},
31
+ {"category": "HARM_CATEGORY_CIVIC_INTEGRITY", "threshold": "BLOCK_NONE"},
32
+ ]
33
+
34
+ def _append_schema_hint(schema: Dict[str, Any], hint: str) -> None:
35
+ """Move fragile validation details into description instead of sending them raw."""
36
+ if not hint:
37
+ return
38
+ desc = schema.get("description")
39
+ schema["description"] = f"{desc} ({hint})" if desc else hint
40
+
41
+
42
+ def _resolve_schema_ref(ref: str, root_schema: Dict[str, Any]) -> Optional[Dict[str, Any]]:
43
+ if not isinstance(ref, str) or not ref.startswith("#/"):
44
+ return None
45
+
46
+ node: Any = root_schema
47
+ for part in ref[2:].split("/"):
48
+ part = part.replace("~1", "/").replace("~0", "~")
49
+ if not isinstance(node, dict) or part not in node:
50
+ return None
51
+ node = node[part]
52
+
53
+ return node if isinstance(node, dict) else None
54
+
55
+
56
+ def _clean_parameters_json_schema(
57
+ schema: Any,
58
+ root_schema: Optional[Dict[str, Any]] = None,
59
+ visited: Optional[set] = None,
60
+ ) -> Any:
61
+ """Clean a tool schema for Code Assist's parametersJsonSchema field."""
62
+ if isinstance(schema, list):
63
+ return [_clean_parameters_json_schema(item, root_schema, visited) for item in schema]
64
+ if not isinstance(schema, dict):
65
+ return schema
66
+
67
+ if root_schema is None:
68
+ root_schema = schema
69
+ if visited is None:
70
+ visited = set()
71
+
72
+ schema_id = id(schema)
73
+ if schema_id in visited:
74
+ return {"type": "object", "description": "circular reference"}
75
+ visited.add(schema_id)
76
+
77
+ ref_key = "$ref" if "$ref" in schema else ("ref" if "ref" in schema else None)
78
+ if ref_key:
79
+ resolved = _resolve_schema_ref(schema[ref_key], root_schema)
80
+ if resolved:
81
+ merged = dict(resolved)
82
+ for key in ("description", "default"):
83
+ if key in schema:
84
+ merged[key] = schema[key]
85
+ schema = merged
86
+
87
+ if "allOf" in schema:
88
+ result: Dict[str, Any] = {}
89
+ for item in schema.get("allOf") or []:
90
+ cleaned_item = _clean_parameters_json_schema(item, root_schema, visited)
91
+ if not isinstance(cleaned_item, dict):
92
+ continue
93
+ if "properties" in cleaned_item:
94
+ result.setdefault("properties", {}).update(cleaned_item["properties"])
95
+ if "required" in cleaned_item:
96
+ result.setdefault("required", []).extend(cleaned_item["required"])
97
+ for key, value in cleaned_item.items():
98
+ if key not in ("properties", "required"):
99
+ result[key] = value
100
+ for key, value in schema.items():
101
+ if key not in ("allOf", "properties", "required"):
102
+ result[key] = value
103
+ elif key in ("properties", "required") and key not in result:
104
+ result[key] = value
105
+ else:
106
+ result = dict(schema)
107
+
108
+ if result.get("nullable") is True:
109
+ _append_schema_hint(result, "nullable")
110
+
111
+ if "type" in result:
112
+ type_value = result["type"]
113
+ if isinstance(type_value, list):
114
+ non_null_types = [
115
+ str(t).lower()
116
+ for t in type_value
117
+ if isinstance(t, str) and t.lower() != "null"
118
+ ]
119
+ if non_null_types:
120
+ result["type"] = non_null_types[0]
121
+ if any(str(t).lower() == "null" for t in type_value):
122
+ _append_schema_hint(result, "nullable")
123
+ else:
124
+ result["type"] = "string"
125
+ elif isinstance(type_value, str):
126
+ lower_type = type_value.lower()
127
+ if lower_type in {"string", "number", "integer", "boolean", "array", "object"}:
128
+ result["type"] = lower_type
129
+ elif lower_type == "null":
130
+ result["type"] = "string"
131
+ _append_schema_hint(result, "nullable")
132
+ else:
133
+ result.pop("type", None)
134
+
135
+ if "anyOf" in result or "oneOf" in result:
136
+ union_key = "anyOf" if "anyOf" in result else "oneOf"
137
+ union_items = result.get(union_key) or []
138
+ cleaned_items = [
139
+ item for item in (
140
+ _clean_parameters_json_schema(item, root_schema, visited)
141
+ for item in union_items
142
+ )
143
+ if isinstance(item, dict)
144
+ ]
145
+ enum_values = [
146
+ item.get("const")
147
+ for item in union_items
148
+ if isinstance(item, dict) and item.get("const") not in ("", None)
149
+ ]
150
+ if enum_values and len(enum_values) == len(union_items):
151
+ result["type"] = "string"
152
+ result["enum"] = [str(v) for v in enum_values]
153
+ else:
154
+ preferred = next(
155
+ (
156
+ item for item in cleaned_items
157
+ if item.get("type") in ("object", "array") or item.get("properties")
158
+ ),
159
+ None,
160
+ )
161
+ if preferred is None:
162
+ preferred = next((item for item in cleaned_items if item.get("type") or item.get("enum")), None)
163
+ if preferred:
164
+ original_description = result.get("description")
165
+ result.update(preferred)
166
+ if original_description:
167
+ _append_schema_hint(result, original_description)
168
+ result.pop("anyOf", None)
169
+ result.pop("oneOf", None)
170
+
171
+ if result.get("type") == "array":
172
+ items = result.get("items")
173
+ if isinstance(items, list):
174
+ if items:
175
+ result["items"] = _clean_parameters_json_schema(items[0], root_schema, visited)
176
+ _append_schema_hint(result, "tuple schema simplified")
177
+ else:
178
+ result.pop("items", None)
179
+ elif isinstance(items, dict):
180
+ result["items"] = _clean_parameters_json_schema(items, root_schema, visited)
181
+
182
+ validation_keys = {
183
+ "default", "minLength", "maxLength", "minimum", "maximum",
184
+ "minItems", "maxItems", "pattern", "format", "uniqueItems",
185
+ }
186
+ for key in list(result.keys()):
187
+ if key in validation_keys:
188
+ value = result.pop(key)
189
+ if value not in (None, "", {}, []):
190
+ _append_schema_hint(result, f"{key}: {json.dumps(value, ensure_ascii=False)}")
191
+
192
+ unsupported_keys = {
193
+ "title", "$schema", "$id", "$ref", "ref", "strict", "nullable",
194
+ "exclusiveMaximum", "exclusiveMinimum", "additionalProperties",
195
+ "allOf", "anyOf", "oneOf", "$defs", "definitions", "example",
196
+ "examples", "readOnly", "writeOnly", "const", "additionalItems",
197
+ "contains", "patternProperties", "dependencies", "propertyNames",
198
+ "if", "then", "else", "contentEncoding", "contentMediaType",
199
+ }
200
+ for key in list(result.keys()):
201
+ if key in unsupported_keys or key.startswith("x-"):
202
+ del result[key]
203
+
204
+ nullable_props = set()
205
+ if isinstance(result.get("properties"), dict):
206
+ cleaned_props = {}
207
+ for prop_name, prop_schema in result["properties"].items():
208
+ if isinstance(prop_schema, dict):
209
+ prop_type = prop_schema.get("type")
210
+ if (
211
+ prop_schema.get("nullable") is True
212
+ or (
213
+ isinstance(prop_type, list)
214
+ and any(str(t).lower() == "null" for t in prop_type)
215
+ )
216
+ ):
217
+ nullable_props.add(prop_name)
218
+ cleaned_props[prop_name] = _clean_parameters_json_schema(prop_schema, root_schema, visited)
219
+ result["properties"] = cleaned_props
220
+
221
+ if "properties" in result and "type" not in result:
222
+ result["type"] = "object"
223
+
224
+ if isinstance(result.get("required"), list):
225
+ prop_names = set(result.get("properties", {}).keys()) if isinstance(result.get("properties"), dict) else None
226
+ required = []
227
+ for item in result["required"]:
228
+ if not isinstance(item, str):
229
+ continue
230
+ if prop_names is not None and item not in prop_names:
231
+ continue
232
+ if item in nullable_props:
233
+ continue
234
+ if item not in required:
235
+ required.append(item)
236
+ if required:
237
+ result["required"] = required
238
+ else:
239
+ result.pop("required", None)
240
+
241
+ return result
242
+
243
+
244
+ def _normalize_tools_for_internal_api(tools: Any) -> Any:
245
+ if not isinstance(tools, list):
246
+ return tools
247
+
248
+ normalized_tools = []
249
+ for tool in tools:
250
+ if not isinstance(tool, dict):
251
+ normalized_tools.append(tool)
252
+ continue
253
+
254
+ normalized_tool = tool.copy()
255
+ declarations = normalized_tool.get("functionDeclarations")
256
+ if declarations is None:
257
+ declarations = normalized_tool.get("function_declarations")
258
+ if isinstance(declarations, list):
259
+ normalized_declarations = []
260
+ for declaration in declarations:
261
+ if not isinstance(declaration, dict):
262
+ normalized_declarations.append(declaration)
263
+ continue
264
+
265
+ normalized_declaration = declaration.copy()
266
+ if "parametersJsonSchema" in normalized_declaration:
267
+ schema = normalized_declaration["parametersJsonSchema"]
268
+ elif "parameters_json_schema" in normalized_declaration:
269
+ schema = normalized_declaration.pop("parameters_json_schema", None)
270
+ else:
271
+ schema = normalized_declaration.pop("parameters", None)
272
+
273
+ normalized_declaration.pop("parameters", None)
274
+ normalized_declaration.pop("parameters_json_schema", None)
275
+ if schema not in (None, {}, []):
276
+ normalized_declaration["parametersJsonSchema"] = _clean_parameters_json_schema(schema)
277
+ else:
278
+ normalized_declaration.pop("parametersJsonSchema", None)
279
+
280
+ normalized_declarations.append(normalized_declaration)
281
+
282
+ normalized_tool.pop("function_declarations", None)
283
+ normalized_tool["functionDeclarations"] = normalized_declarations
284
+
285
+ normalized_tools.append(normalized_tool)
286
+
287
+ return normalized_tools
288
+
289
+
290
+ def _ensure_empty_tool_schema_for_claude(tools: Any, model_name: str) -> Any:
291
+ if "claude" not in (model_name or "").lower() or not isinstance(tools, list):
292
+ return tools
293
+
294
+ normalized_tools = []
295
+ for tool in tools:
296
+ if not isinstance(tool, dict):
297
+ normalized_tools.append(tool)
298
+ continue
299
+
300
+ normalized_tool = tool.copy()
301
+ custom_tool = normalized_tool.get("custom")
302
+ if isinstance(custom_tool, dict) and "input_schema" not in custom_tool:
303
+ normalized_custom = custom_tool.copy()
304
+ normalized_custom["input_schema"] = {"type": "object", "properties": {}}
305
+ normalized_tool["custom"] = normalized_custom
306
+
307
+ declarations = normalized_tool.get("functionDeclarations")
308
+ if declarations is None:
309
+ declarations = normalized_tool.get("function_declarations")
310
+ if isinstance(declarations, list):
311
+ normalized_declarations = []
312
+ for declaration in declarations:
313
+ if not isinstance(declaration, dict):
314
+ normalized_declarations.append(declaration)
315
+ continue
316
+ normalized_declaration = declaration.copy()
317
+ if (
318
+ "parametersJsonSchema" not in normalized_declaration
319
+ and "parameters_json_schema" in normalized_declaration
320
+ ):
321
+ normalized_declaration["parametersJsonSchema"] = normalized_declaration.pop("parameters_json_schema")
322
+
323
+ if "parametersJsonSchema" not in normalized_declaration:
324
+ normalized_declaration["parametersJsonSchema"] = {
325
+ "type": "object",
326
+ "properties": {},
327
+ }
328
+ normalized_declarations.append(normalized_declaration)
329
+ normalized_tool.pop("function_declarations", None)
330
+ normalized_tool["functionDeclarations"] = normalized_declarations
331
+
332
+ normalized_tools.append(normalized_tool)
333
+
334
+ return normalized_tools
335
+
336
+
337
+ def _should_skip_thought_signature(part: Dict[str, Any], model_name: str) -> bool:
338
+ if "claude" in (model_name or "").lower():
339
+ return False
340
+
341
+ return (
342
+ "functionCall" in part
343
+ or "function_call" in part
344
+ or part.get("thought") is True
345
+ or "thoughtSignature" in part
346
+ or "thought_signature" in part
347
+ )
348
+
349
+
350
+ def _normalize_part_thought_signature(part: Dict[str, Any], model_name: str) -> Dict[str, Any]:
351
+ normalized = part.copy()
352
+ if _should_skip_thought_signature(normalized, model_name):
353
+ normalized.pop("thought_signature", None)
354
+ normalized["thoughtSignature"] = SKIP_THOUGHT_SIGNATURE_VALIDATOR
355
+ return normalized
356
+
357
+
358
+ SUPPORTED_ASPECT_RATIOS = [
359
+ (1, 1), (2, 3), (3, 2), (3, 4), (4, 3),
360
+ (4, 5), (5, 4), (9, 16), (16, 9), (21, 9),
361
+ ]
362
+
363
+
364
+ def _parse_size_to_image_config(size_str: str) -> Dict[str, str]:
365
+ """
366
+ 解析用户传入的 size 参数为 Gemini imageConfig 参数
367
+
368
+ 支持格式: "1024x1536", "1024*1536", "1024X1536"
369
+
370
+ Returns:
371
+ 包含 aspectRatio 和/或 imageSize 的字典
372
+ """
373
+ import re
374
+
375
+ config = {}
376
+ size_str = size_str.strip()
377
+
378
+ match = re.match(r"^(\d+)\s*[xX*×]\s*(\d+)$", size_str)
379
+ if not match:
380
+ return config
381
+
382
+ width, height = int(match.group(1)), int(match.group(2))
383
+
384
+ if width <= 0 or height <= 0:
385
+ return config
386
+
387
+ # 计算最接近的支持宽高比
388
+ target_ratio = width / height
389
+ best_ratio = None
390
+ best_diff = float("inf")
391
+ for w, h in SUPPORTED_ASPECT_RATIOS:
392
+ diff = abs(target_ratio - w / h)
393
+ if diff < best_diff:
394
+ best_diff = diff
395
+ best_ratio = f"{w}:{h}"
396
+ if best_ratio:
397
+ config["aspectRatio"] = best_ratio
398
+
399
+ # 根据最大边长确定 imageSize(使用最接近的档位)
400
+ max_dim = max(width, height)
401
+ if max_dim <= 1280:
402
+ config["imageSize"] = "1K"
403
+ elif max_dim <= 2560:
404
+ config["imageSize"] = "2K"
405
+ else:
406
+ config["imageSize"] = "4K"
407
+
408
+ return config
409
+
410
+
411
+ def prepare_image_generation_request(
412
+ request_body: Dict[str, Any],
413
+ model: str
414
+ ) -> Dict[str, Any]:
415
+ """
416
+ 图像生成模型请求体后处理
417
+
418
+ 支持三种方式指定图片参数(优先级从高到低):
419
+ 1. size 参数: 如 "1024x1536",自动计算 aspectRatio 和 imageSize
420
+ 2. 模型名后缀: 如 -4k, -2k, -16x9, -1x1
421
+ 3. 默认值: 不设置额外参数
422
+
423
+ Args:
424
+ request_body: 原始请求体
425
+ model: 模型名称
426
+
427
+ Returns:
428
+ 处理后的请求体
429
+ """
430
+ request_body = request_body.copy()
431
+ model_lower = model.lower()
432
+
433
+ # 优先使用 size 参数
434
+ size_str = request_body.pop("size", None)
435
+ if size_str:
436
+ image_config = _parse_size_to_image_config(size_str)
437
+ log.debug(f"[IMAGE] 从 size 参数 '{size_str}' 解析: {image_config}")
438
+ else:
439
+ # 从模型名后缀解析
440
+ image_size = "4K" if "-4k" in model_lower else "2K" if "-2k" in model_lower else None
441
+
442
+ aspect_ratio = None
443
+ for suffix, ratio in [
444
+ ("-21x9", "21:9"), ("-16x9", "16:9"), ("-9x16", "9:16"),
445
+ ("-4x3", "4:3"), ("-3x4", "3:4"), ("-1x1", "1:1")
446
+ ]:
447
+ if suffix in model_lower:
448
+ aspect_ratio = ratio
449
+ break
450
+
451
+ image_config = {}
452
+ if aspect_ratio:
453
+ image_config["aspectRatio"] = aspect_ratio
454
+ if image_size:
455
+ image_config["imageSize"] = image_size
456
+
457
+ request_body["model"] = "gemini-3.1-flash-image" # 统一使用基础模型名
458
+ request_body["generationConfig"] = {
459
+ "candidateCount": 1,
460
+ "imageConfig": image_config
461
+ }
462
+
463
+ # 移除不需要的字段
464
+ for key in ("systemInstruction", "tools", "toolConfig"):
465
+ request_body.pop(key, None)
466
+
467
+ return request_body
468
+
469
+
470
+ # ==================== 模型特性辅助函数 ====================
471
+
472
+ def get_base_model_name(model_name: str) -> str:
473
+ """移除模型名称中的后缀,返回基础模型名"""
474
+ # 按照从长到短的顺序排列,避免短后缀先于长后缀被匹配
475
+ suffixes = [
476
+ "-maxthinking", "-nothinking", # 兼容旧模式
477
+ "-minimal", "-medium", "-search", "-think", # 中等长度后缀
478
+ "-high", "-max", "-low" # 短后缀
479
+ ]
480
+ result = model_name
481
+ changed = True
482
+ # 持续循环直到没有任何后缀可以移除
483
+ while changed:
484
+ changed = False
485
+ for suffix in suffixes:
486
+ if result.endswith(suffix):
487
+ result = result[:-len(suffix)]
488
+ changed = True
489
+ # 不使用 break,继续检查是否还有其他后缀
490
+ return result
491
+
492
+
493
+ def get_thinking_settings(model_name: str) -> tuple[Optional[int], Optional[str]]:
494
+ """
495
+ 根据模型名称获取思考配置
496
+
497
+ 支持两种模式:
498
+ 1. CLI 模式思考预算 (Gemini 2.5 系列): -max, -high, -medium, -low, -minimal
499
+ 2. CLI 模式思考等级 (Gemini 3 Preview 系列): -high, -medium, -low, -minimal (仅 3-flash)
500
+ 3. 兼容旧模式: -maxthinking, -nothinking (不返回给用户)
501
+
502
+ Returns:
503
+ (thinking_budget, thinking_level): 思考预算和思考等级
504
+ """
505
+ base_model = get_base_model_name(model_name)
506
+
507
+ # ========== 兼容旧模式 (不返回给用户) ==========
508
+ if "-nothinking" in model_name:
509
+ # nothinking 模式: 限制思考
510
+ if "flash" in base_model:
511
+ return 0, None
512
+ return 128, None
513
+ elif "-maxthinking" in model_name:
514
+ # maxthinking 模式: 最大思考预算
515
+ budget = 24576 if "flash" in base_model else 32768
516
+ if "gemini-3" in base_model:
517
+ # Gemini 3 系列不支持 thinkingBudget,返回 high 等级
518
+ return None, "high"
519
+ else:
520
+ return budget, None
521
+
522
+ # ========== 新 CLI 模式: 基于思考预算/等级 ==========
523
+
524
+ # Gemini 3 Preview 系列: 使用 thinkingLevel
525
+ if "gemini-3" in base_model:
526
+ if "-high" in model_name:
527
+ return None, "high"
528
+ elif "-medium" in model_name:
529
+ # 仅 3-flash-preview 支持 medium
530
+ if "flash" in base_model:
531
+ return None, "medium"
532
+ # pro 系列不支持 medium,返回 Default
533
+ return None, None
534
+ elif "-low" in model_name:
535
+ return None, "low"
536
+ elif "-minimal" in model_name:
537
+ return None, None
538
+ else:
539
+ # Default: 不设置 thinking 配置
540
+ return None, None
541
+
542
+ # Gemini 2.5 系列: 使用 thinkingBudget
543
+ elif "gemini-2.5" in base_model:
544
+ if "-max" in model_name:
545
+ # 2.5-flash-max: 24576, 2.5-pro-max: 32768
546
+ budget = 24576 if "flash" in base_model else 32768
547
+ return budget, None
548
+ elif "-high" in model_name:
549
+ # 2.5-flash-high: 16000, 2.5-pro-high: 16000
550
+ return 16000, None
551
+ elif "-medium" in model_name:
552
+ # 2.5-flash-medium: 8192, 2.5-pro-medium: 8192
553
+ return 8192, None
554
+ elif "-low" in model_name:
555
+ # 2.5-flash-low: 1024, 2.5-pro-low: 1024
556
+ return 1024, None
557
+ elif "-minimal" in model_name:
558
+ # 2.5-flash-minimal: 0, 2.5-pro-minimal: 128
559
+ budget = 0 if "flash" in base_model else 128
560
+ return budget, None
561
+ else:
562
+ # Default: 不设置 thinking budget
563
+ return None, None
564
+
565
+ # 其他模型: 不设置 thinking 配置
566
+ return None, None
567
+
568
+
569
+ def is_search_model(model_name: str) -> bool:
570
+ """检查是否为搜索模型"""
571
+ return "-search" in model_name
572
+
573
+
574
+ # ==================== 统一的 Gemini 请求后处理 ====================
575
+
576
+ def is_thinking_model(model_name: str) -> bool:
577
+ """检查是否为思考模型 (包含 -thinking 或 pro)"""
578
+ return "think" in model_name or "pro" in model_name.lower()
579
+
580
+
581
+ async def normalize_gemini_request(
582
+ request: Dict[str, Any],
583
+ mode: str = "geminicli"
584
+ ) -> Dict[str, Any]:
585
+ """
586
+ 规范化 Gemini 请求
587
+
588
+ 处理逻辑:
589
+ 1. 模型特性处理 (thinking config, search tools)
590
+ 3. 参数范围限制 (maxOutputTokens, topK)
591
+ 4. 工具清理
592
+
593
+ Args:
594
+ request: 原始请求字典
595
+ mode: 模式 ("geminicli" 或 "antigravity")
596
+
597
+ Returns:
598
+ 规范化后的请求
599
+ """
600
+ # 导入配置函数
601
+ from config import get_return_thoughts_to_frontend
602
+
603
+ result = request.copy()
604
+ model = result.get("model", "")
605
+ generation_config = (result.get("generationConfig") or {}).copy() # 创建副本避免修改原对象
606
+ tools = result.get("tools")
607
+ system_instruction = result.get("systemInstruction") or result.get("system_instructions")
608
+
609
+ # 记录原始请求
610
+ log.debug(f"[GEMINI_FIX] 原始请求 - 模型: {model}, mode: {mode}, generationConfig: {generation_config}")
611
+
612
+ # 获取配置值
613
+ return_thoughts = await get_return_thoughts_to_frontend()
614
+
615
+ # ========== 模式特定处理 ==========
616
+ if mode == "geminicli":
617
+ # 1. 思考设置
618
+ # 优先使用 get_thinking_settings 获取的思考预算和等级
619
+ thinking_budget, thinking_level = get_thinking_settings(model)
620
+
621
+ # 其次使用传入的思考预算(如果未从模型名称获取)
622
+ if thinking_budget is None and thinking_level is None:
623
+ thinking_budget = generation_config.get("thinkingConfig", {}).get("thinkingBudget")
624
+ thinking_level = generation_config.get("thinkingConfig", {}).get("thinkingLevel")
625
+
626
+ # 假如 is_thinking_model 为真或者思考预算/等级不为空,设置 thinkingConfig
627
+ if is_thinking_model(model) or thinking_budget is not None or thinking_level is not None:
628
+ # 确保 thinkingConfig 存在
629
+ if "thinkingConfig" not in generation_config:
630
+ generation_config["thinkingConfig"] = {}
631
+
632
+ thinking_config = generation_config["thinkingConfig"]
633
+
634
+ # 设置思考预算或等级(互斥)
635
+ if thinking_budget is not None:
636
+ thinking_config["thinkingBudget"] = thinking_budget
637
+ thinking_config.pop("thinkingLevel", None) # 避免与 thinkingBudget 冲突
638
+ elif thinking_level is not None:
639
+ thinking_config["thinkingLevel"] = thinking_level
640
+ thinking_config.pop("thinkingBudget", None) # 避免与 thinkingLevel 冲突
641
+
642
+ # includeThoughts 逻辑:
643
+ # 1. 如果是 pro 模型,为 return_thoughts
644
+ # 2. 如果不是 pro 模型,检查是否有思考预算或思考等级
645
+ base_model = get_base_model_name(model)
646
+ if "pro" in base_model:
647
+ include_thoughts = return_thoughts
648
+ elif "3-flash" in base_model:
649
+ if thinking_level is None:
650
+ include_thoughts = False
651
+ else:
652
+ include_thoughts = return_thoughts
653
+ else:
654
+ # 非 pro 模型: 有思考预算或等级才包含思考
655
+ # 注意: 思考预算为 0 时不包含思考
656
+ if thinking_budget is None or thinking_budget == 0:
657
+ include_thoughts = False
658
+ else:
659
+ include_thoughts = return_thoughts
660
+
661
+ thinking_config["includeThoughts"] = include_thoughts
662
+
663
+ # 2. 搜索模型添加 Google Search
664
+ if is_search_model(model):
665
+ result_tools = result.get("tools") or []
666
+ result["tools"] = result_tools
667
+ if not any(tool.get("googleSearch") for tool in result_tools if isinstance(tool, dict)):
668
+ result_tools.append({"googleSearch": {}})
669
+
670
+ # 3. 模型名称处理
671
+ result["model"] = get_base_model_name(model)
672
+
673
+ elif mode == "antigravity":
674
+
675
+ '''
676
+ # 1. 处理 system_instruction
677
+ 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]"
678
+
679
+ # 提取原有的 parts(如果存在)
680
+ existing_parts = []
681
+ if system_instruction:
682
+ if isinstance(system_instruction, dict):
683
+ existing_parts = system_instruction.get("parts", [])
684
+
685
+ # custom_prompt 始终放在第一位,原有内容整体后移
686
+ result["systemInstruction"] = {
687
+ "parts": [{"text": custom_prompt}] + existing_parts
688
+ }
689
+ '''
690
+
691
+ # 2. 判断图片模型
692
+ if "image" in model.lower():
693
+ # 调用图片生成专用处理函数
694
+ return prepare_image_generation_request(result, model)
695
+ else:
696
+ # 3. 思考模型处理
697
+ if is_thinking_model(model) or ("thinkingBudget" in generation_config.get("thinkingConfig", {}) and generation_config["thinkingConfig"]["thinkingBudget"] != 0):
698
+ # 直接设置 thinkingConfig
699
+ if "thinkingConfig" not in generation_config:
700
+ generation_config["thinkingConfig"] = {}
701
+
702
+ thinking_config = generation_config["thinkingConfig"]
703
+ # 优先使用传入的思考预算,否则使用默认值
704
+ if "thinkingBudget" not in thinking_config:
705
+ thinking_config["thinkingBudget"] = 1024
706
+ thinking_config.pop("thinkingLevel", None) # 避免与 thinkingBudget 冲突
707
+ thinking_config["includeThoughts"] = return_thoughts
708
+
709
+ # 检查最后一个 assistant 消息是否以 thinking 块开始
710
+ contents = result.get("contents", [])
711
+
712
+ if "claude" in model.lower():
713
+ # 检测是否有工具调用(MCP场景)
714
+ has_tool_calls = any(
715
+ isinstance(content, dict) and
716
+ any(
717
+ isinstance(part, dict) and ("functionCall" in part or "function_call" in part)
718
+ for part in content.get("parts", [])
719
+ )
720
+ for content in contents
721
+ )
722
+
723
+ if has_tool_calls:
724
+ # MCP 场景:检测到工具调用,移除 thinkingConfig
725
+ log.warning(f"[ANTIGRAVITY] 检测到工具调用(MCP场景),移除 thinkingConfig 避免失效")
726
+ generation_config.pop("thinkingConfig", None)
727
+ else:
728
+ # 非 MCP 场景:填充思考块
729
+ # log.warning(f"[ANTIGRAVITY] 最后一个 assistant 消息不以 thinking 块开始,自动填充思考块")
730
+
731
+ # 找到最后一个 model 角色的 content
732
+ for i in range(len(contents) - 1, -1, -1):
733
+ content = contents[i]
734
+ if isinstance(content, dict) and content.get("role") == "model":
735
+ # 在 parts 开头插入思考块(使用官方跳过验证的虚拟签名)
736
+ parts = content.get("parts", [])
737
+ thinking_part = {
738
+ "text": "...",
739
+ # "thought": True, # 标记为思考块
740
+ "thoughtSignature": "skip_thought_signature_validator" # 官方文档推荐的虚拟签名
741
+ }
742
+ # 如果第一个 part 不是 thinking,则插入
743
+ if not parts or not (isinstance(parts[0], dict) and ("thought" in parts[0] or "thoughtSignature" in parts[0])):
744
+ content["parts"] = [thinking_part] + parts
745
+ log.debug(f"[ANTIGRAVITY] 已在最后一个 assistant 消息开头插入思考块(含跳过验证签名)")
746
+ break
747
+
748
+ # 移除 -thinking 后缀
749
+ model = model.replace("-thinking", "")
750
+
751
+ # 4. Claude 模型关键词映射
752
+ # 使用关键词匹配而不是精确匹配,更灵活地处理各种变体
753
+ original_model = model
754
+ if "opus" in model.lower():
755
+ model = "claude-opus-4-6-thinking"
756
+ elif "sonnet" in model.lower():
757
+ model = "claude-sonnet-4-6"
758
+ elif "haiku" in model.lower():
759
+ model = "gemini-2.5-flash"
760
+ elif "claude" in model.lower():
761
+ # Claude 模型兜底:如果包含 claude 但不是 opus/sonnet/haiku
762
+ model = "claude-sonnet-4-6"
763
+
764
+ result["model"] = model
765
+ if original_model != model:
766
+ log.debug(f"[ANTIGRAVITY] 映射模型: {original_model} -> {model}")
767
+
768
+ # 5. 模型特殊处理:循环移除末尾的 model 消息,保证以用户消息结尾
769
+ # 因为该模型不支持预填充
770
+ if "claude-opus-4-6-thinking" in model.lower() or "claude-sonnet-4-6" in model.lower():
771
+ contents = result.get("contents", [])
772
+ removed_count = 0
773
+ while contents and isinstance(contents[-1], dict) and contents[-1].get("role") == "model":
774
+ contents.pop()
775
+ removed_count += 1
776
+ if removed_count > 0:
777
+ log.warning(f"[ANTIGRAVITY] {model} 不支持预填充,移除了 {removed_count} 条末尾 model 消息")
778
+ result["contents"] = contents
779
+
780
+ # 6. 移除 antigravity 模式不支持的字段
781
+ generation_config.pop("presencePenalty", None)
782
+ generation_config.pop("frequencyPenalty", None)
783
+ generation_config.pop("stopSequences", None)
784
+
785
+ # ========== 公共处理 ==========
786
+
787
+ # 1. 安全设置覆盖
788
+ if "tools" in result:
789
+ result["tools"] = _normalize_tools_for_internal_api(result.get("tools"))
790
+ result["tools"] = _ensure_empty_tool_schema_for_claude(result.get("tools"), model)
791
+
792
+ if "lite" in model.lower():
793
+ result["safetySettings"] = LITE_SAFETY_SETTINGS
794
+ else:
795
+ result["safetySettings"] = DEFAULT_SAFETY_SETTINGS
796
+
797
+ # 2. 参数范围限制
798
+ if generation_config:
799
+ # 强制设置 maxOutputTokens 为 64000
800
+ generation_config["maxOutputTokens"] = 64000
801
+ # 强制设置 topK 为 64
802
+ generation_config["topK"] = 64
803
+
804
+ if "contents" in result:
805
+ cleaned_contents = []
806
+ for content in result["contents"]:
807
+ if isinstance(content, dict) and "parts" in content:
808
+ # 过滤掉空的或无效的 parts
809
+ valid_parts = []
810
+ for part in content["parts"]:
811
+ if not isinstance(part, dict):
812
+ continue
813
+
814
+ # 检查 part 是否有有效的非空值
815
+ # 过滤掉空字典或所有值都为空的 part
816
+ has_valid_value = any(
817
+ value not in (None, "", {}, [])
818
+ for key, value in part.items()
819
+ if key != "thought" # thought 字段可以为空
820
+ )
821
+
822
+ if has_valid_value:
823
+ part = _normalize_part_thought_signature(part, model)
824
+
825
+ # 修复 text 字段:确保是字符串而不是列表
826
+ if "text" in part:
827
+ text_value = part["text"]
828
+ if isinstance(text_value, list):
829
+ # 如果是列表,合并为字符串
830
+ log.warning(f"[GEMINI_FIX] text 字段是列表,自动合并: {text_value}")
831
+ part["text"] = " ".join(str(t) for t in text_value if t)
832
+ elif isinstance(text_value, str):
833
+ # 清理尾随空格
834
+ part["text"] = text_value.rstrip()
835
+ else:
836
+ # 其他类型转为字符串
837
+ log.warning(f"[GEMINI_FIX] text 字段类型异常 ({type(text_value)}), 转为字符串: {text_value}")
838
+ part["text"] = str(text_value)
839
+
840
+ valid_parts.append(part)
841
+ else:
842
+ log.warning(f"[GEMINI_FIX] 移除空的或无效的 part: {part}")
843
+
844
+ # 只添加有有效 parts 的 content
845
+ if valid_parts:
846
+ cleaned_content = content.copy()
847
+ cleaned_content["parts"] = valid_parts
848
+ cleaned_contents.append(cleaned_content)
849
+ else:
850
+ log.warning(f"[GEMINI_FIX] 跳过没有有效 parts 的 content: {content.get('role')}")
851
+ else:
852
+ cleaned_contents.append(content)
853
+
854
+ result["contents"] = cleaned_contents
855
+
856
+ if generation_config:
857
+ result["generationConfig"] = generation_config
858
+
859
+ return result
src/converter/openai2gemini.py ADDED
@@ -0,0 +1,1801 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ decode_tool_id_and_signature,
15
+ is_internal_placeholder_text,
16
+ is_skip_thought_signature_placeholder,
17
+ SKIP_THOUGHT_SIGNATURE_VALIDATOR,
18
+ )
19
+ from src.converter.utils import merge_system_messages
20
+
21
+ from log import log
22
+
23
+ def _convert_usage_metadata(usage_metadata: Dict[str, Any]) -> Optional[Dict[str, Any]]:
24
+ """
25
+ 将Gemini的usageMetadata转换为OpenAI格式的usage字段
26
+
27
+ Args:
28
+ usage_metadata: Gemini API的usageMetadata字段
29
+
30
+ Returns:
31
+ OpenAI格式的usage字典,如果没有usage数据则返回None
32
+ """
33
+ if not usage_metadata:
34
+ return None
35
+
36
+ prompt_tokens_total = int(usage_metadata.get("promptTokenCount", 0) or 0)
37
+ cached_tokens = int(usage_metadata.get("cachedContentTokenCount", 0) or 0)
38
+ prompt_tokens = max(prompt_tokens_total - cached_tokens, 0)
39
+ completion_tokens = int(usage_metadata.get("candidatesTokenCount", 0) or 0)
40
+ raw_total_tokens = int(
41
+ usage_metadata.get(
42
+ "totalTokenCount",
43
+ prompt_tokens_total + completion_tokens + int(usage_metadata.get("thoughtsTokenCount", 0) or 0),
44
+ )
45
+ or 0
46
+ )
47
+
48
+ usage = {
49
+ "prompt_tokens": prompt_tokens,
50
+ "completion_tokens": completion_tokens,
51
+ "total_tokens": max(raw_total_tokens - cached_tokens, prompt_tokens + completion_tokens),
52
+ }
53
+
54
+ if cached_tokens > 0:
55
+ usage["prompt_tokens_details"] = {"cached_tokens": cached_tokens}
56
+
57
+ reasoning_tokens = int(usage_metadata.get("thoughtsTokenCount", 0) or 0)
58
+ if reasoning_tokens > 0:
59
+ usage["completion_tokens_details"] = {"reasoning_tokens": reasoning_tokens}
60
+
61
+ return usage
62
+
63
+
64
+ def _build_message_with_reasoning(role: str, content: str, reasoning_content: str) -> dict:
65
+ """构建包含可选推理内容的消息对象"""
66
+ message = {"role": role, "content": content}
67
+
68
+ # 如果有thinking tokens,添加reasoning_content
69
+ if reasoning_content:
70
+ message["reasoning_content"] = reasoning_content
71
+
72
+ return message
73
+
74
+
75
+ def _map_finish_reason(gemini_reason: str) -> str:
76
+ """
77
+ 将Gemini结束原因映射到OpenAI结束原因
78
+
79
+ Args:
80
+ gemini_reason: 来自Gemini API的结束原因
81
+
82
+ Returns:
83
+ OpenAI兼容的结束原因
84
+ """
85
+ if gemini_reason == "STOP":
86
+ return "stop"
87
+ elif gemini_reason == "MAX_TOKENS":
88
+ return "length"
89
+ elif gemini_reason in ["SAFETY", "RECITATION"]:
90
+ return "content_filter"
91
+ else:
92
+ # 对于 None 或未知的 finishReason,返回 "stop" 作为默认值
93
+ # 避免返回 None 导致 MCP 客户端误判为响应未完成而循环调用
94
+ return "stop"
95
+
96
+
97
+ # ==================== Tool Conversion Functions ====================
98
+
99
+
100
+ def _normalize_function_name(name: str) -> str:
101
+ """
102
+ 规范化函数名以符合 Gemini API 要求
103
+
104
+ 规则:
105
+ - 必须以字母或下划线开头
106
+ - 只能包含 a-z, A-Z, 0-9, 下划线, 英文句点, 英文短划线
107
+ - 最大长度 64 个字符
108
+
109
+ 转换策略:
110
+ 1. 中文字符转换为拼音
111
+ 2. 将非法字符替换为下划线
112
+ 3. 如果以非字母/下划线开头,添加下划线前缀
113
+ 4. 截断到 64 个字符
114
+
115
+ Args:
116
+ name: 原始函数名
117
+
118
+ Returns:
119
+ 规范化后的函数名
120
+ """
121
+ import re
122
+
123
+ if not name:
124
+ return "_unnamed_function"
125
+
126
+ # 步骤1:转换中文字符为拼音
127
+ if re.search(r"[\u4e00-\u9fff]", name):
128
+ try:
129
+ parts = []
130
+ for char in name:
131
+ if "\u4e00" <= char <= "\u9fff":
132
+ # 中文字符转换为拼音
133
+ pinyin = lazy_pinyin(char, style=Style.NORMAL)
134
+ parts.append("".join(pinyin))
135
+ else:
136
+ parts.append(char)
137
+ normalized = "".join(parts)
138
+ except ImportError:
139
+ log.warning("pypinyin not installed, cannot convert Chinese characters to pinyin")
140
+ normalized = name
141
+ else:
142
+ normalized = name
143
+
144
+ # 步骤2:将非法字符替换为下划线
145
+ # 合法字符:a-z, A-Z, 0-9, _, ., -
146
+ normalized = re.sub(r"[^a-zA-Z0-9_.\-]", "_", normalized)
147
+
148
+ # 步骤3:确保以字母或下划线开头
149
+ if normalized and not (normalized[0].isalpha() or normalized[0] == "_"):
150
+ # 以数字、点或短横线开头,添加下划线前缀
151
+ normalized = "_" + normalized
152
+
153
+ # 步骤4:截断到 64 个字符
154
+ if len(normalized) > 64:
155
+ normalized = normalized[:64]
156
+
157
+ # 步骤5:确保不为空
158
+ if not normalized:
159
+ normalized = "_unnamed_function"
160
+
161
+ return normalized
162
+
163
+
164
+ def _resolve_ref(ref: str, root_schema: Dict[str, Any]) -> Optional[Dict[str, Any]]:
165
+ """
166
+ 解析 $ref 或 ref 引用
167
+
168
+ Args:
169
+ ref: 引用路径,如 "#/definitions/MyType" 或 "#/$defs/MyType"
170
+ root_schema: 根 schema 对象
171
+
172
+ Returns:
173
+ 解析后的 schema,如果失败返回 None
174
+ """
175
+ if not isinstance(ref, str):
176
+ return None
177
+
178
+ if not ref.startswith('#/'):
179
+ # 尝试在 definitions 或 $defs 中查找
180
+ for key in ["definitions", "$defs"]:
181
+ if key in root_schema and ref in root_schema[key]:
182
+ return root_schema[key][ref]
183
+ return None
184
+
185
+ path = ref[2:].split('/')
186
+ current = root_schema
187
+
188
+ for segment in path:
189
+ if isinstance(current, dict) and segment in current:
190
+ current = current[segment]
191
+ else:
192
+ return None
193
+
194
+ return current if isinstance(current, dict) else None
195
+
196
+
197
+ def _clean_schema_for_claude(schema: Any, root_schema: Optional[Dict[str, Any]] = None, visited: Optional[set] = None) -> Any:
198
+ """
199
+ 清理 JSON Schema,转换为 Claude API 支持的格式(符合 JSON Schema draft 2020-12)
200
+
201
+ 处理逻辑:
202
+ 1. 解析 $ref 引用
203
+ 2. 合并 allOf 中的 schema
204
+ 3. 转换 anyOf 为更兼容的格式
205
+ 4. 保持标准 JSON Schema 类型(不转换为大写)
206
+ 5. 处理 array 的 items
207
+ 6. 清理 Claude 不支持的字段
208
+
209
+ Args:
210
+ schema: JSON Schema 对象
211
+ root_schema: 根 schema(用于解析 $ref)
212
+ visited: 已访问的对象集合(防止循环引用)
213
+
214
+ Returns:
215
+ 清理后的 schema
216
+ """
217
+ # 非字典类型直接返回
218
+ if not isinstance(schema, dict):
219
+ return schema
220
+
221
+ # 初始化
222
+ if root_schema is None:
223
+ root_schema = schema
224
+ if visited is None:
225
+ visited = set()
226
+
227
+ # 防止循环引用
228
+ schema_id = id(schema)
229
+ if schema_id in visited:
230
+ return schema
231
+ visited.add(schema_id)
232
+
233
+ # 创建副本避免修改原对象
234
+ result = {}
235
+
236
+ # 1. 处理 $ref
237
+ if "$ref" in schema:
238
+ resolved = _resolve_ref(schema["$ref"], root_schema)
239
+ if resolved:
240
+ import copy
241
+ result = copy.deepcopy(resolved)
242
+ for key, value in schema.items():
243
+ if key != "$ref":
244
+ result[key] = value
245
+ schema = result
246
+ result = {}
247
+
248
+ # 2. 处理 allOf(合并所有 schema)
249
+ if "allOf" in schema:
250
+ all_of_schemas = schema["allOf"]
251
+ for item in all_of_schemas:
252
+ cleaned_item = _clean_schema_for_claude(item, root_schema, visited)
253
+
254
+ if "properties" in cleaned_item:
255
+ if "properties" not in result:
256
+ result["properties"] = {}
257
+ result["properties"].update(cleaned_item["properties"])
258
+
259
+ if "required" in cleaned_item:
260
+ if "required" not in result:
261
+ result["required"] = []
262
+ result["required"].extend(cleaned_item["required"])
263
+
264
+ for key, value in cleaned_item.items():
265
+ if key not in ["properties", "required"]:
266
+ result[key] = value
267
+
268
+ for key, value in schema.items():
269
+ if key not in ["allOf", "properties", "required"]:
270
+ result[key] = value
271
+ elif key in ["properties", "required"] and key not in result:
272
+ result[key] = value
273
+ else:
274
+ result = dict(schema)
275
+
276
+ # 3. 处理 type 数组(如 ["string", "null"])
277
+ if "type" in result:
278
+ type_value = result["type"]
279
+ if isinstance(type_value, list):
280
+ # Claude 支持 type 数组,保持不变
281
+ pass
282
+
283
+ # 4. 处理 array 的 items
284
+ if result.get("type") == "array":
285
+ if "items" not in result:
286
+ result["items"] = {}
287
+ elif isinstance(result["items"], list):
288
+ # Tuple 定义,检查是否所有元素类型相同
289
+ tuple_items = result["items"]
290
+ first_type = tuple_items[0].get("type") if tuple_items else None
291
+ is_homogeneous = all(item.get("type") == first_type for item in tuple_items)
292
+
293
+ if is_homogeneous and first_type:
294
+ result["items"] = _clean_schema_for_claude(tuple_items[0], root_schema, visited)
295
+ else:
296
+ # 异质元组,使用 anyOf 表示
297
+ result["items"] = {
298
+ "anyOf": [_clean_schema_for_claude(item, root_schema, visited) for item in tuple_items]
299
+ }
300
+ else:
301
+ result["items"] = _clean_schema_for_claude(result["items"], root_schema, visited)
302
+
303
+ # 5. 处理 anyOf(保持 anyOf,递归清理)
304
+ if "anyOf" in result:
305
+ result["anyOf"] = [_clean_schema_for_claude(item, root_schema, visited) for item in result["anyOf"]]
306
+
307
+ # 6. 清理 Claude 不支持的字段(根据 JSON Schema 2020-12)
308
+ # Claude API 对某些字段比较严格,移除可能导致问题的字段
309
+ unsupported_keys = {
310
+ "title", "$schema", "strict",
311
+ "additionalItems", # 废弃字段,使用 items 替代
312
+ "exclusiveMaximum", "exclusiveMinimum", # 在 2020-12 中这些应该是数值而非布尔值
313
+ "$defs", "definitions", # 移除 definitions 相关字段避免冲突
314
+ "example", "examples", "readOnly", "writeOnly",
315
+ "const", # const 可能导致问题
316
+ "contentEncoding", "contentMediaType",
317
+ "oneOf", # oneOf 可能导致问题,用 anyOf 替代
318
+ "patternProperties", "dependencies", "propertyNames", # Google API 不支持
319
+ }
320
+
321
+ for key in list(result.keys()):
322
+ if key in unsupported_keys:
323
+ del result[key]
324
+
325
+ # 递归处理 additionalProperties(如果存在)
326
+ if "additionalProperties" in result and isinstance(result["additionalProperties"], dict):
327
+ result["additionalProperties"] = _clean_schema_for_claude(result["additionalProperties"], root_schema, visited)
328
+
329
+ # 7. 递归处理 properties
330
+ if "properties" in result:
331
+ cleaned_props = {}
332
+ for prop_name, prop_schema in result["properties"].items():
333
+ cleaned_props[prop_name] = _clean_schema_for_claude(prop_schema, root_schema, visited)
334
+ result["properties"] = cleaned_props
335
+
336
+ # 8. 确保有 type 字段(如果有 properties 但没有 type)
337
+ if "properties" in result and "type" not in result:
338
+ result["type"] = "object"
339
+
340
+ # 9. 去重 required 数组
341
+ if "required" in result and isinstance(result["required"], list):
342
+ result["required"] = list(dict.fromkeys(result["required"]))
343
+
344
+ return result
345
+
346
+
347
+ def _clean_schema_for_gemini(schema: Any, root_schema: Optional[Dict[str, Any]] = None, visited: Optional[set] = None) -> Any:
348
+ """
349
+ 清理 JSON Schema,转换为 Gemini 支持的格式
350
+
351
+ 参考 worker.mjs 的 transformOpenApiSchemaToGemini 实现
352
+
353
+ 处理逻辑:
354
+ 1. 解析 $ref 引用
355
+ 2. 合并 allOf 中的 schema
356
+ 3. 转换 anyOf 为 enum(如果可能)
357
+ 4. 类型映射(string -> STRING)
358
+ 5. 处理 ARRAY 的 items(包括 Tuple)
359
+ 6. 将 default 值移到 description
360
+ 7. 清理不支持的字段
361
+
362
+ Args:
363
+ schema: JSON Schema 对象
364
+ root_schema: 根 schema(用于解析 $ref)
365
+ visited: 已访问的对象集合(防止循环引用)
366
+
367
+ Returns:
368
+ 清理后的 schema
369
+ """
370
+ # 非字典类型直接返回
371
+ if not isinstance(schema, dict):
372
+ return schema
373
+
374
+ # 初始化
375
+ if root_schema is None:
376
+ root_schema = schema
377
+ if visited is None:
378
+ visited = set()
379
+
380
+ # 防止循环引用
381
+ schema_id = id(schema)
382
+ if schema_id in visited:
383
+ return schema
384
+ visited.add(schema_id)
385
+
386
+ # 创建副本避免修改原对象
387
+ result = {}
388
+
389
+ # 1. 处理 $ref 或 ref
390
+ ref_key = "$ref" if "$ref" in schema else ("ref" if "ref" in schema else None)
391
+ if ref_key:
392
+ resolved = _resolve_ref(schema[ref_key], root_schema)
393
+ if resolved:
394
+ # 检测循环引用
395
+ resolved_id = id(resolved)
396
+ if resolved_id in visited:
397
+ return {"type": "OBJECT", "description": "(circular reference)"}
398
+
399
+ visited.add(resolved_id)
400
+ # 合并解析后的 schema
401
+ merged = dict(resolved)
402
+
403
+ # 重要:根据 Gemini API 限制,当存在引用时,只能并列存在 description 和 default
404
+ # 其他字段(如 type, properties 等)必须丢弃,否则会触发 400 错误
405
+ for key in ["description", "default"]:
406
+ if key in schema:
407
+ merged[key] = schema[key]
408
+
409
+ schema = merged
410
+ result = {}
411
+
412
+ # 2. 处理 allOf(合并所有 schema)
413
+ if "allOf" in schema:
414
+ all_of_schemas = schema["allOf"]
415
+ for item in all_of_schemas:
416
+ cleaned_item = _clean_schema_for_gemini(item, root_schema, visited)
417
+
418
+ # 合并 properties
419
+ if "properties" in cleaned_item:
420
+ if "properties" not in result:
421
+ result["properties"] = {}
422
+ result["properties"].update(cleaned_item["properties"])
423
+
424
+ # 合并 required
425
+ if "required" in cleaned_item:
426
+ if "required" not in result:
427
+ result["required"] = []
428
+ result["required"].extend(cleaned_item["required"])
429
+
430
+ # 合并其他字段(简单覆盖)
431
+ for key, value in cleaned_item.items():
432
+ if key not in ["properties", "required"]:
433
+ result[key] = value
434
+
435
+ # 复制其他字段
436
+ for key, value in schema.items():
437
+ if key not in ["allOf", "properties", "required"]:
438
+ result[key] = value
439
+ elif key in ["properties", "required"] and key not in result:
440
+ result[key] = value
441
+ else:
442
+ # 复制所有字段
443
+ result = dict(schema)
444
+
445
+ # 3. 类型映射(转换为大写)
446
+ # 注意:Gemini API 的 type 字段必须是字符串,不能是数组
447
+ if "type" in result:
448
+ type_value = result["type"]
449
+
450
+ # 如果 type 是列表,提取主要类型(非 null)
451
+ if isinstance(type_value, list):
452
+ primary_type = next((t for t in type_value if t != "null"), None)
453
+ type_value = primary_type if primary_type else "STRING" # 默认为 STRING
454
+
455
+ # 类型映射
456
+ type_map = {
457
+ "string": "STRING",
458
+ "number": "NUMBER",
459
+ "integer": "INTEGER",
460
+ "boolean": "BOOLEAN",
461
+ "array": "ARRAY",
462
+ "object": "OBJECT",
463
+ }
464
+
465
+ if isinstance(type_value, str) and type_value.lower() in type_map:
466
+ # 确保 result["type"] 是字符串而不是列表
467
+ result["type"] = type_map[type_value.lower()]
468
+ else:
469
+ # 未知类型,删除该字段
470
+ del result["type"]
471
+
472
+ # 4. 处理 ARRAY 的 items
473
+ if result.get("type") == "ARRAY":
474
+ if "items" not in result:
475
+ # 没有 items,默认允许任意类型
476
+ result["items"] = {}
477
+ elif isinstance(result["items"], list):
478
+ # Tuple 定义(items 是数组)
479
+ tuple_items = result["items"]
480
+
481
+ # 提取类型信息用于 description
482
+ tuple_types = [item.get("type", "any") for item in tuple_items]
483
+ tuple_desc = f"(Tuple: [{', '.join(tuple_types)}])"
484
+
485
+ original_desc = result.get("description", "")
486
+ result["description"] = f"{original_desc} {tuple_desc}".strip()
487
+
488
+ # 检查是否所有元素类型相同
489
+ first_type = tuple_items[0].get("type") if tuple_items else None
490
+ is_homogeneous = all(item.get("type") == first_type for item in tuple_items)
491
+
492
+ if is_homogeneous and first_type:
493
+ # 同质元组,转换为 List<Type>
494
+ result["items"] = _clean_schema_for_gemini(tuple_items[0], root_schema, visited)
495
+ else:
496
+ # 异质元组,Gemini 不支持,设为 {}
497
+ result["items"] = {}
498
+ else:
499
+ # 递归处理 items
500
+ result["items"] = _clean_schema_for_gemini(result["items"], root_schema, visited)
501
+
502
+ # 5. 处理 anyOf(尝试转换为 enum)
503
+ if "anyOf" in result:
504
+ any_of_schemas = result["anyOf"]
505
+
506
+ # 递归处理每个 schema
507
+ cleaned_any_of = [_clean_schema_for_gemini(item, root_schema, visited) for item in any_of_schemas]
508
+
509
+ # 尝试提取 enum
510
+ if all("const" in item for item in cleaned_any_of):
511
+ enum_values = [
512
+ str(item["const"])
513
+ for item in cleaned_any_of
514
+ if item.get("const") not in ["", None]
515
+ ]
516
+ if enum_values:
517
+ result["type"] = "STRING"
518
+ result["enum"] = enum_values
519
+ elif "type" not in result:
520
+ # 如果不是 enum,尝试取第一个有效的类型定义
521
+ first_valid = next((item for item in cleaned_any_of if item.get("type") or item.get("enum")), None)
522
+ if first_valid:
523
+ result.update(first_valid)
524
+
525
+ # 删除 anyOf
526
+ del result["anyOf"]
527
+
528
+ # 6. 将 default 值移到 description
529
+ if "default" in result:
530
+ default_value = result["default"]
531
+ original_desc = result.get("description", "")
532
+ result["description"] = f"{original_desc} (Default: {json.dumps(default_value)})".strip()
533
+ del result["default"]
534
+
535
+ # 7. 清理不支持的字段
536
+ unsupported_keys = {
537
+ "title", "$schema", "$ref", "ref", "strict", "exclusiveMaximum",
538
+ "exclusiveMinimum", "additionalProperties", "oneOf", "allOf",
539
+ "$defs", "definitions", "example", "examples", "readOnly",
540
+ "writeOnly", "const", "additionalItems", "contains",
541
+ "patternProperties", "dependencies", "propertyNames",
542
+ "if", "then", "else", "contentEncoding", "contentMediaType"
543
+ }
544
+
545
+ for key in list(result.keys()):
546
+ if key in unsupported_keys:
547
+ del result[key]
548
+
549
+ # 8. 递归处理 properties
550
+ if "properties" in result:
551
+ cleaned_props = {}
552
+ for prop_name, prop_schema in result["properties"].items():
553
+ cleaned_props[prop_name] = _clean_schema_for_gemini(prop_schema, root_schema, visited)
554
+ result["properties"] = cleaned_props
555
+
556
+ # 9. 确保有 type 字段(如果有 properties 但没有 type)
557
+ if "properties" in result and "type" not in result:
558
+ result["type"] = "OBJECT"
559
+
560
+ # 10. 去重 required 数组
561
+ if "required" in result and isinstance(result["required"], list):
562
+ result["required"] = list(dict.fromkeys(result["required"])) # 保持顺序去重
563
+
564
+ return result
565
+
566
+
567
+ def _append_schema_hint(schema: Dict[str, Any], hint: str) -> None:
568
+ """把不兼容的校验信息挪到 description 里,避免上游直接拒收。"""
569
+ if not hint:
570
+ return
571
+ desc = schema.get("description")
572
+ schema["description"] = f"{desc} ({hint})" if desc else hint
573
+
574
+
575
+ def _clean_schema_for_parameters_json_schema(
576
+ schema: Any,
577
+ root_schema: Optional[Dict[str, Any]] = None,
578
+ visited: Optional[set] = None,
579
+ ) -> Any:
580
+ """
581
+ 清理 JSON Schema,供 Gemini CLI 内部接口的 parametersJsonSchema 使用。
582
+
583
+ Code Assist 的内部接口更接近官方 Gemini CLI:工具参数应放在
584
+ parametersJsonSchema 中,并保持 JSON Schema 的小写 type。
585
+ """
586
+ if not isinstance(schema, dict):
587
+ return schema
588
+
589
+ if root_schema is None:
590
+ root_schema = schema
591
+ if visited is None:
592
+ visited = set()
593
+
594
+ schema_id = id(schema)
595
+ if schema_id in visited:
596
+ return {"type": "object", "description": "(circular reference)"}
597
+ visited.add(schema_id)
598
+
599
+ result: Dict[str, Any]
600
+
601
+ ref_key = "$ref" if "$ref" in schema else ("ref" if "ref" in schema else None)
602
+ if ref_key:
603
+ resolved = _resolve_ref(schema[ref_key], root_schema)
604
+ if resolved:
605
+ import copy
606
+ result = copy.deepcopy(resolved)
607
+ for key in ("description", "default"):
608
+ if key in schema:
609
+ result[key] = schema[key]
610
+ schema = result
611
+
612
+ if "allOf" in schema:
613
+ result = {}
614
+ for item in schema.get("allOf") or []:
615
+ cleaned_item = _clean_schema_for_parameters_json_schema(item, root_schema, visited)
616
+ if not isinstance(cleaned_item, dict):
617
+ continue
618
+ if "properties" in cleaned_item:
619
+ result.setdefault("properties", {}).update(cleaned_item["properties"])
620
+ if "required" in cleaned_item:
621
+ result.setdefault("required", []).extend(cleaned_item["required"])
622
+ for key, value in cleaned_item.items():
623
+ if key not in ("properties", "required"):
624
+ result[key] = value
625
+ for key, value in schema.items():
626
+ if key not in ("allOf", "properties", "required"):
627
+ result[key] = value
628
+ elif key in ("properties", "required") and key not in result:
629
+ result[key] = value
630
+ else:
631
+ result = dict(schema)
632
+
633
+ if "type" in result:
634
+ type_value = result["type"]
635
+ if isinstance(type_value, list):
636
+ non_null_types = [t for t in type_value if isinstance(t, str) and t.lower() != "null"]
637
+ if non_null_types:
638
+ result["type"] = non_null_types[0]
639
+ if "null" in [str(t).lower() for t in type_value]:
640
+ _append_schema_hint(result, "nullable")
641
+ else:
642
+ result["type"] = "string"
643
+ elif isinstance(type_value, str):
644
+ lower_type = type_value.lower()
645
+ if lower_type in {"string", "number", "integer", "boolean", "array", "object", "null"}:
646
+ result["type"] = "string" if lower_type == "null" else lower_type
647
+ else:
648
+ del result["type"]
649
+
650
+ if "anyOf" in result or "oneOf" in result:
651
+ union_key = "anyOf" if "anyOf" in result else "oneOf"
652
+ union_items = result.get(union_key) or []
653
+ cleaned_items = [
654
+ item for item in (
655
+ _clean_schema_for_parameters_json_schema(item, root_schema, visited)
656
+ for item in union_items
657
+ )
658
+ if isinstance(item, dict)
659
+ ]
660
+ enum_values = [
661
+ item.get("const")
662
+ for item in union_items
663
+ if isinstance(item, dict) and item.get("const") not in ("", None)
664
+ ]
665
+ if enum_values and len(enum_values) == len(union_items):
666
+ result["type"] = "string"
667
+ result["enum"] = [str(v) for v in enum_values]
668
+ else:
669
+ preferred = next(
670
+ (
671
+ item for item in cleaned_items
672
+ if item.get("type") in ("object", "array") or item.get("properties")
673
+ ),
674
+ None,
675
+ )
676
+ if preferred is None:
677
+ preferred = next((item for item in cleaned_items if item.get("type") or item.get("enum")), None)
678
+ if preferred:
679
+ existing_description = result.get("description")
680
+ result.update(preferred)
681
+ if existing_description:
682
+ _append_schema_hint(result, existing_description)
683
+ result.pop("anyOf", None)
684
+ result.pop("oneOf", None)
685
+
686
+ if result.get("type") == "array":
687
+ items = result.get("items")
688
+ if isinstance(items, list):
689
+ if items:
690
+ result["items"] = _clean_schema_for_parameters_json_schema(items[0], root_schema, visited)
691
+ _append_schema_hint(result, "tuple schema simplified")
692
+ else:
693
+ result.pop("items", None)
694
+ elif isinstance(items, dict):
695
+ result["items"] = _clean_schema_for_parameters_json_schema(items, root_schema, visited)
696
+
697
+ validation_keys = {
698
+ "default", "minLength", "maxLength", "minimum", "maximum",
699
+ "minItems", "maxItems", "pattern", "format", "uniqueItems",
700
+ }
701
+ for key in list(result.keys()):
702
+ if key in validation_keys:
703
+ value = result.pop(key)
704
+ if value not in (None, "", {}, []):
705
+ _append_schema_hint(result, f"{key}: {json.dumps(value, ensure_ascii=False)}")
706
+
707
+ unsupported_keys = {
708
+ "title", "$schema", "$id", "$ref", "ref", "strict",
709
+ "exclusiveMaximum", "exclusiveMinimum", "additionalProperties",
710
+ "allOf", "anyOf", "oneOf", "$defs", "definitions", "example",
711
+ "examples", "readOnly", "writeOnly", "const", "additionalItems",
712
+ "contains", "patternProperties", "dependencies", "propertyNames",
713
+ "if", "then", "else", "contentEncoding", "contentMediaType",
714
+ }
715
+ for key in list(result.keys()):
716
+ if key in unsupported_keys or key.startswith("x-"):
717
+ del result[key]
718
+
719
+ nullable_props = set()
720
+ if "properties" in result and isinstance(result["properties"], dict):
721
+ cleaned_props = {}
722
+ for prop_name, prop_schema in result["properties"].items():
723
+ if isinstance(prop_schema, dict):
724
+ prop_type = prop_schema.get("type")
725
+ if isinstance(prop_type, list) and any(str(t).lower() == "null" for t in prop_type):
726
+ nullable_props.add(prop_name)
727
+ cleaned_props[prop_name] = _clean_schema_for_parameters_json_schema(prop_schema, root_schema, visited)
728
+ result["properties"] = cleaned_props
729
+
730
+ if "properties" in result and "type" not in result:
731
+ result["type"] = "object"
732
+
733
+ if "required" in result and isinstance(result["required"], list):
734
+ prop_names = set(result.get("properties", {}).keys()) if isinstance(result.get("properties"), dict) else None
735
+ required = []
736
+ for item in result["required"]:
737
+ if not isinstance(item, str):
738
+ continue
739
+ if prop_names is not None and item not in prop_names:
740
+ continue
741
+ if item in nullable_props:
742
+ continue
743
+ if item not in required:
744
+ required.append(item)
745
+ if required:
746
+ result["required"] = required
747
+ else:
748
+ result.pop("required", None)
749
+
750
+ return result
751
+
752
+
753
+ def fix_tool_call_args_types(
754
+ args: Dict[str, Any],
755
+ parameters_schema: Dict[str, Any]
756
+ ) -> Dict[str, Any]:
757
+ """
758
+ 根据工具的参数 schema 修正函数调用参数的类型
759
+
760
+ 例如:将字符串 "5" 转换为数字 5,根据 schema 中的 type 定义
761
+
762
+ Args:
763
+ args: 函数调用的参数字典
764
+ parameters_schema: 工具定义中的 parameters schema
765
+
766
+ Returns:
767
+ 类型修正后的参数字典
768
+ """
769
+ if not args or not parameters_schema:
770
+ return args
771
+
772
+ properties = parameters_schema.get("properties", {})
773
+ if not properties:
774
+ return args
775
+
776
+ fixed_args = {}
777
+ for key, value in args.items():
778
+ if key not in properties:
779
+ # 参数不在 schema 中,保持原样
780
+ fixed_args[key] = value
781
+ continue
782
+
783
+ param_schema = properties[key]
784
+ param_type = param_schema.get("type")
785
+
786
+ # 根据 schema 中的类型修正参数值
787
+ if param_type == "number" or param_type == "integer":
788
+ # 如果值是字符串,尝试转换为数字
789
+ if isinstance(value, str):
790
+ try:
791
+ if param_type == "integer":
792
+ fixed_args[key] = int(value)
793
+ else:
794
+ # 尝试转换为 float,如果是整数则保持为 int
795
+ num_value = float(value)
796
+ fixed_args[key] = int(num_value) if num_value.is_integer() else num_value
797
+ log.debug(f"[OPENAI2GEMINI] 修正参数类型: {key} '{value}' -> {fixed_args[key]} ({param_type})")
798
+ except (ValueError, AttributeError):
799
+ # 转换失败,保持原样
800
+ fixed_args[key] = value
801
+ log.warning(f"[OPENAI2GEMINI] 无法将参数 {key} 的值 '{value}' 转换为 {param_type}")
802
+ else:
803
+ fixed_args[key] = value
804
+ elif param_type == "boolean":
805
+ # 如果值是字符串,转换为布尔值
806
+ if isinstance(value, str):
807
+ if value.lower() in ("true", "1", "yes"):
808
+ fixed_args[key] = True
809
+ elif value.lower() in ("false", "0", "no"):
810
+ fixed_args[key] = False
811
+ else:
812
+ fixed_args[key] = value
813
+ if fixed_args[key] != value:
814
+ log.debug(f"[OPENAI2GEMINI] 修正参数类型: {key} '{value}' -> {fixed_args[key]} (boolean)")
815
+ else:
816
+ fixed_args[key] = value
817
+ elif param_type == "string":
818
+ # 如果值不是字符串,转换为字符串
819
+ if not isinstance(value, str):
820
+ fixed_args[key] = str(value)
821
+ log.debug(f"[OPENAI2GEMINI] 修正参数类型: {key} {value} -> '{fixed_args[key]}' (string)")
822
+ else:
823
+ fixed_args[key] = value
824
+ else:
825
+ # 其他类型(array, object 等)保持原样
826
+ fixed_args[key] = value
827
+
828
+ return fixed_args
829
+
830
+
831
+ def convert_openai_tools_to_gemini(openai_tools: List, model: str = "") -> List[Dict[str, Any]]:
832
+ """
833
+ 将 OpenAI tools 格式转换为 Gemini functionDeclarations 格式
834
+
835
+ Args:
836
+ openai_tools: OpenAI 格式的工具列表(可能是字典或 Pydantic 模型)
837
+ model: 模型名称(用于判断是否为 Claude 模型)
838
+
839
+ Returns:
840
+ Gemini 格式的工具列表
841
+ """
842
+ if not openai_tools:
843
+ return []
844
+
845
+ # 判断是否为 Claude 模型
846
+ is_claude_model = "claude" in model.lower()
847
+
848
+ function_declarations = []
849
+
850
+ for tool in openai_tools:
851
+ if tool.get("type") != "function":
852
+ log.warning(f"Skipping non-function tool type: {tool.get('type')}")
853
+ continue
854
+
855
+ function = tool.get("function")
856
+ if not function:
857
+ log.warning("Tool missing 'function' field")
858
+ continue
859
+
860
+ # 获取并规范化函数名
861
+ original_name = function.get("name")
862
+ if not original_name:
863
+ log.warning("Tool missing 'name' field, using default")
864
+ original_name = "_unnamed_function"
865
+
866
+ normalized_name = _normalize_function_name(original_name)
867
+
868
+ # 如果名称被修改了,记录日志
869
+ if normalized_name != original_name:
870
+ log.debug(f"Function name normalized: '{original_name}' -> '{normalized_name}'")
871
+
872
+ # 构建 Gemini function declaration
873
+ declaration = {
874
+ "name": normalized_name,
875
+ "description": function.get("description", ""),
876
+ }
877
+
878
+ # 添加参数(如果有)- Gemini CLI 内部接口更适合 parametersJsonSchema
879
+ if "parameters" in function:
880
+ if is_claude_model:
881
+ cleaned_params = _clean_schema_for_parameters_json_schema(function["parameters"])
882
+ log.debug(f"[OPENAI2GEMINI] Using Claude schema cleaning for tool: {normalized_name}")
883
+ else:
884
+ cleaned_params = _clean_schema_for_parameters_json_schema(function["parameters"])
885
+
886
+ if cleaned_params:
887
+ declaration["parametersJsonSchema"] = cleaned_params
888
+ elif is_claude_model:
889
+ declaration["parametersJsonSchema"] = {"type": "object", "properties": {}}
890
+ elif is_claude_model:
891
+ declaration["parametersJsonSchema"] = {"type": "object", "properties": {}}
892
+
893
+ function_declarations.append(declaration)
894
+
895
+ if not function_declarations:
896
+ return []
897
+
898
+ # Gemini 格式:工具数组中包含 functionDeclarations
899
+ return [{"functionDeclarations": function_declarations}]
900
+
901
+
902
+ def convert_tool_choice_to_tool_config(tool_choice: Union[str, Dict[str, Any]]) -> Dict[str, Any]:
903
+ """
904
+ 将 OpenAI tool_choice 转换为 Gemini toolConfig
905
+
906
+ Args:
907
+ tool_choice: OpenAI 格式的 tool_choice
908
+
909
+ Returns:
910
+ Gemini 格式的 toolConfig
911
+ """
912
+ if isinstance(tool_choice, str):
913
+ if tool_choice == "auto":
914
+ return {"functionCallingConfig": {"mode": "AUTO"}}
915
+ elif tool_choice == "none":
916
+ return {"functionCallingConfig": {"mode": "NONE"}}
917
+ elif tool_choice == "required":
918
+ return {"functionCallingConfig": {"mode": "ANY"}}
919
+ elif isinstance(tool_choice, dict):
920
+ # {"type": "function", "function": {"name": "my_function"}}
921
+ if tool_choice.get("type") == "function":
922
+ function_name = tool_choice.get("function", {}).get("name")
923
+ if function_name:
924
+ return {
925
+ "functionCallingConfig": {
926
+ "mode": "ANY",
927
+ "allowedFunctionNames": [function_name],
928
+ }
929
+ }
930
+
931
+ # 默认返回 AUTO 模式
932
+ return {"functionCallingConfig": {"mode": "AUTO"}}
933
+
934
+
935
+ def convert_tool_message_to_function_response(message, all_messages: List = None) -> Dict[str, Any]:
936
+ """
937
+ 将 OpenAI 的 tool role 消息转换为 Gemini functionResponse
938
+
939
+ Args:
940
+ message: OpenAI 格式的工具消息
941
+ all_messages: 所有消息的列表,用于查找 tool_call_id 对应的函数名
942
+
943
+ Returns:
944
+ Gemini 格式的 functionResponse part
945
+ """
946
+ # 获取 name 字段
947
+ name = getattr(message, "name", None)
948
+ encoded_tool_call_id = getattr(message, "tool_call_id", None) or ""
949
+
950
+ # 解码获取原始ID(functionResponse不需要签名)
951
+ original_tool_call_id, _ = decode_tool_id_and_signature(encoded_tool_call_id)
952
+
953
+ # 如果没有 name,尝试从 all_messages 中查找对应的 tool_call_id
954
+ # 注意:使用编码ID查找,因为存储的是编码ID
955
+ if not name and encoded_tool_call_id and all_messages:
956
+ for msg in all_messages:
957
+ if getattr(msg, "role", None) == "assistant" and hasattr(msg, "tool_calls") and msg.tool_calls:
958
+ for tool_call in msg.tool_calls:
959
+ if getattr(tool_call, "id", None) == encoded_tool_call_id:
960
+ func = getattr(tool_call, "function", None)
961
+ if func:
962
+ name = getattr(func, "name", None)
963
+ break
964
+ if name:
965
+ break
966
+
967
+ # 最终兜底:如果仍然没有 name,使用默认值
968
+ if not name:
969
+ name = "unknown_function"
970
+ log.warning(f"Tool message missing function name, using default: {name}")
971
+
972
+ try:
973
+ # 尝试将 content 解析为 JSON
974
+ response_data = (
975
+ json.loads(message.content) if isinstance(message.content, str) else message.content
976
+ )
977
+ except (json.JSONDecodeError, TypeError):
978
+ # 如果不是有效的 JSON,包装为对象
979
+ response_data = {"result": str(message.content)}
980
+
981
+ # 确保 response_data 是字典类型(Gemini API 要求 response 必须是对象)
982
+ if not isinstance(response_data, dict):
983
+ response_data = {"result": response_data}
984
+
985
+ return {"functionResponse": {"id": original_tool_call_id, "name": name, "response": response_data}}
986
+
987
+
988
+ def _reverse_transform_value(value: Any) -> Any:
989
+ """
990
+ 将值转换回原始类型(Gemini 可能将所有值转为字符串)
991
+
992
+ 仅处理 Gemini 在工具参数中常见的布尔/空值字符串化情况,
993
+ 不再对数字字符串做启发式转换,避免把 schema 声明为 string
994
+ 的参数错误还原成 integer。
995
+
996
+ 参考 worker.mjs 的 reverseTransformValue
997
+
998
+ Args:
999
+ value: 要转换的值
1000
+
1001
+ Returns:
1002
+ 转换后的值
1003
+ """
1004
+ if not isinstance(value, str):
1005
+ return value
1006
+
1007
+ # 布尔值
1008
+ if value == 'true':
1009
+ return True
1010
+ if value == 'false':
1011
+ return False
1012
+
1013
+ # null
1014
+ if value == 'null':
1015
+ return None
1016
+
1017
+ # 其他情况保持字符串
1018
+ return value
1019
+
1020
+
1021
+ def _reverse_transform_args(args: Any) -> Any:
1022
+ """
1023
+ 递归转换函数参数,将字符串转回原始类型
1024
+
1025
+ 参考 worker.mjs 的 reverseTransformArgs
1026
+
1027
+ Args:
1028
+ args: 函数参数(可能是字典、列表或其他类型)
1029
+
1030
+ Returns:
1031
+ 转换后的参数
1032
+ """
1033
+ if not isinstance(args, (dict, list)):
1034
+ return args
1035
+
1036
+ if isinstance(args, list):
1037
+ return [_reverse_transform_args(item) for item in args]
1038
+
1039
+ # 处理字典
1040
+ result = {}
1041
+ for key, value in args.items():
1042
+ if isinstance(value, (dict, list)):
1043
+ result[key] = _reverse_transform_args(value)
1044
+ else:
1045
+ result[key] = _reverse_transform_value(value)
1046
+
1047
+ return result
1048
+
1049
+
1050
+ def extract_tool_calls_from_parts(
1051
+ parts: List[Dict[str, Any]], is_streaming: bool = False
1052
+ ) -> Tuple[List[Dict[str, Any]], str]:
1053
+ """
1054
+ 从 Gemini response parts 中提取工具调用和文本内容
1055
+
1056
+ Args:
1057
+ parts: Gemini response 的 parts 数组
1058
+ is_streaming: 是否为流式响应(流式响应需要添加 index 字段)
1059
+
1060
+ Returns:
1061
+ (tool_calls, text_content) 元组
1062
+ """
1063
+ tool_calls = []
1064
+ text_content = ""
1065
+
1066
+ for idx, part in enumerate(parts):
1067
+ # 检查是否是函数调用
1068
+ if "functionCall" in part:
1069
+ function_call = part["functionCall"]
1070
+ # 获取原始ID或生成新ID
1071
+ original_id = function_call.get("id") or f"call_{uuid.uuid4().hex[:24]}"
1072
+ # 获取参数并转换类型
1073
+ args = function_call.get("args", {})
1074
+ # 将字符串类型的值转回原始类型
1075
+ args = _reverse_transform_args(args)
1076
+
1077
+ tool_call = {
1078
+ "id": original_id,
1079
+ "type": "function",
1080
+ "function": {
1081
+ "name": function_call.get("name", "nameless_function"),
1082
+ "arguments": json.dumps(args),
1083
+ },
1084
+ }
1085
+ # 流式响应需要 index 字段
1086
+ if is_streaming:
1087
+ tool_call["index"] = idx
1088
+ tool_calls.append(tool_call)
1089
+
1090
+ # 提取文本内容(排除 thinking tokens)
1091
+ elif "text" in part and not part.get("thought", False):
1092
+ text = part["text"]
1093
+ if (
1094
+ is_skip_thought_signature_placeholder(part)
1095
+ or is_internal_placeholder_text(text)
1096
+ ):
1097
+ continue
1098
+ text_content += text
1099
+
1100
+ return tool_calls, text_content
1101
+
1102
+
1103
+ def extract_images_from_content(content: Any) -> Dict[str, Any]:
1104
+ """
1105
+ 从 OpenAI content 中提取文本和图片
1106
+
1107
+ Args:
1108
+ content: OpenAI 消息的 content 字段(可能是字符串或列表)
1109
+
1110
+ Returns:
1111
+ 包含 text 和 images 的字典
1112
+ """
1113
+ result = {"text": "", "images": []}
1114
+
1115
+ if isinstance(content, str):
1116
+ result["text"] = content
1117
+ elif isinstance(content, list):
1118
+ for item in content:
1119
+ if isinstance(item, dict):
1120
+ if item.get("type") == "text":
1121
+ result["text"] += item.get("text", "")
1122
+ elif item.get("type") == "image_url":
1123
+ image_url = item.get("image_url", {}).get("url", "")
1124
+ # 解析 data:image/png;base64,xxx 格式
1125
+ if image_url.startswith("data:image/"):
1126
+ import re
1127
+ match = re.match(r"^data:image/(\w+);base64,(.+)$", image_url)
1128
+ if match:
1129
+ mime_type = match.group(1)
1130
+ base64_data = match.group(2)
1131
+ result["images"].append({
1132
+ "inlineData": {
1133
+ "mimeType": f"image/{mime_type}",
1134
+ "data": base64_data
1135
+ }
1136
+ })
1137
+
1138
+ return result
1139
+
1140
+
1141
+ def _sanitize_openai_roundtrip_signatures(contents: List[Dict[str, Any]]) -> None:
1142
+ """
1143
+ OpenAI-compatible clients may round-trip Gemini thinking signatures through
1144
+ fields we do not fully control. Keep tool calls on the safe bypass sentinel
1145
+ and drop signatures everywhere else to avoid Corrupted thought signature.
1146
+ """
1147
+ for content in contents:
1148
+ if not isinstance(content, dict):
1149
+ continue
1150
+ parts = content.get("parts")
1151
+ if not isinstance(parts, list):
1152
+ continue
1153
+
1154
+ for index, part in enumerate(parts):
1155
+ if not isinstance(part, dict):
1156
+ continue
1157
+
1158
+ sanitized_part = part.copy()
1159
+ if "thoughtSignature" in sanitized_part:
1160
+ if "functionCall" in sanitized_part or "function_call" in sanitized_part:
1161
+ sanitized_part["thoughtSignature"] = SKIP_THOUGHT_SIGNATURE_VALIDATOR
1162
+ else:
1163
+ sanitized_part.pop("thoughtSignature", None)
1164
+
1165
+ if sanitized_part.get("thought") is True and not sanitized_part.get("thoughtSignature"):
1166
+ sanitized_part.pop("thought", None)
1167
+
1168
+ parts[index] = sanitized_part
1169
+
1170
+
1171
+ async def convert_openai_to_gemini_request(openai_request: Dict[str, Any]) -> Dict[str, Any]:
1172
+ """
1173
+ 将 OpenAI 格式请求体转换为 Gemini 格式请求体
1174
+
1175
+ 注意: 此函数只负责基础转换,不包含 normalize_gemini_request 中的处理
1176
+ (如 thinking config, search tools, 参数范围限制等)
1177
+
1178
+ Args:
1179
+ openai_request: OpenAI 格式的请求体字典,包含:
1180
+ - messages: 消息列表
1181
+ - temperature, top_p, max_tokens, stop 等生成参数
1182
+ - tools, tool_choice (可选)
1183
+ - response_format (可选)
1184
+
1185
+ Returns:
1186
+ Gemini 格式的请求体字典,包含:
1187
+ - contents: 转换后的消息内容
1188
+ - generationConfig: 生成配置
1189
+ - systemInstruction: 系统指令 (如果有)
1190
+ - tools, toolConfig (如果有)
1191
+ """
1192
+ # 处理连续的system消息(兼容性模式)
1193
+ openai_request = await merge_system_messages(openai_request)
1194
+
1195
+ contents = []
1196
+
1197
+ # 提取消息列表
1198
+ messages = openai_request.get("messages", [])
1199
+
1200
+ # 构建 tool_call_id -> (name, original_id, signature) 的映射
1201
+ tool_call_mapping = {}
1202
+ for msg in messages:
1203
+ if msg.get("role") == "assistant" and msg.get("tool_calls"):
1204
+ for tc in msg["tool_calls"]:
1205
+ encoded_id = tc.get("id", "")
1206
+ func_name = tc.get("function", {}).get("name") or ""
1207
+ if encoded_id:
1208
+ # 解码获取原始ID和签名
1209
+ original_id, _ = decode_tool_id_and_signature(encoded_id)
1210
+ tool_call_mapping[encoded_id] = (func_name, original_id, None)
1211
+
1212
+ # 构建工具名称到参数 schema 的映射(用于类型修正)
1213
+ tool_schemas = {}
1214
+ if "tools" in openai_request and openai_request["tools"]:
1215
+ for tool in openai_request["tools"]:
1216
+ if tool.get("type") == "function":
1217
+ function = tool.get("function", {})
1218
+ func_name = function.get("name")
1219
+ if func_name:
1220
+ tool_schemas[func_name] = function.get("parameters", {})
1221
+
1222
+ # 用于累积连续的 tool message 的 functionResponse parts
1223
+ pending_tool_parts = []
1224
+
1225
+ def flush_pending_tool_parts():
1226
+ """将累积的 tool parts 作为单个 contents 条目追加"""
1227
+ nonlocal pending_tool_parts
1228
+ if pending_tool_parts:
1229
+ contents.append({
1230
+ "role": "user",
1231
+ "parts": pending_tool_parts
1232
+ })
1233
+ pending_tool_parts = []
1234
+
1235
+ for message in messages:
1236
+ role = message.get("role", "user")
1237
+ content = message.get("content", "")
1238
+
1239
+ # 处理工具消息(tool role)- 累积到 pending_tool_parts
1240
+ if role == "tool":
1241
+ tool_call_id = message.get("tool_call_id", "")
1242
+ func_name = message.get("name")
1243
+
1244
+ # 使用映射表查找
1245
+ if tool_call_id in tool_call_mapping:
1246
+ func_name, original_id, _ = tool_call_mapping[tool_call_id]
1247
+ else:
1248
+ # 如果没有name,尝试从消息列表中查找
1249
+ if not func_name and tool_call_id:
1250
+ for msg in messages:
1251
+ if msg.get("role") == "assistant" and msg.get("tool_calls"):
1252
+ for tc in msg["tool_calls"]:
1253
+ if tc.get("id") == tool_call_id:
1254
+ func_name = tc.get("function", {}).get("name")
1255
+ break
1256
+ if func_name:
1257
+ break
1258
+
1259
+ # 解码 tool_call_id 获取原始 ID
1260
+ original_id, _ = decode_tool_id_and_signature(tool_call_id)
1261
+
1262
+ # 最终兜底:确保 func_name 不为空
1263
+ if not func_name:
1264
+ func_name = "unknown_function"
1265
+ log.warning(f"Tool message missing function name for tool_call_id={tool_call_id}, using default: {func_name}")
1266
+
1267
+ # 解析响应数据
1268
+ try:
1269
+ response_data = json.loads(content) if isinstance(content, str) else content
1270
+ except (json.JSONDecodeError, TypeError):
1271
+ response_data = {"result": str(content)}
1272
+
1273
+ # 确保 response_data 是字典类型(Gemini API 要求 response 必须是对象)
1274
+ if not isinstance(response_data, dict):
1275
+ response_data = {"result": response_data}
1276
+
1277
+ # 累积 functionResponse part(不立即追加到 contents)
1278
+ pending_tool_parts.append({
1279
+ "functionResponse": {
1280
+ "id": original_id,
1281
+ "name": func_name,
1282
+ "response": response_data
1283
+ }
1284
+ })
1285
+ continue
1286
+
1287
+ # 遇到非 tool 消息时,先 flush 累积的 tool parts
1288
+ flush_pending_tool_parts()
1289
+
1290
+ # system 消息已经由 merge_system_messages 处理,这里跳过
1291
+ if role == "system":
1292
+ continue
1293
+
1294
+ # 将OpenAI角色映射到Gemini角色
1295
+ if role == "assistant":
1296
+ role = "model"
1297
+
1298
+ # 检查是否有tool_calls
1299
+ tool_calls = message.get("tool_calls")
1300
+ if tool_calls:
1301
+ parts = []
1302
+
1303
+ # 如果有文本内容,先添加文本
1304
+ if content:
1305
+ parts.append({"text": content})
1306
+
1307
+ # 添加每个工具调用
1308
+ for tool_call in tool_calls:
1309
+ try:
1310
+ args = (
1311
+ json.loads(tool_call["function"]["arguments"])
1312
+ if isinstance(tool_call["function"]["arguments"], str)
1313
+ else tool_call["function"]["arguments"]
1314
+ )
1315
+
1316
+ # 根据工具的 schema 修正参数类型
1317
+ func_name = tool_call["function"]["name"]
1318
+ if func_name in tool_schemas:
1319
+ args = fix_tool_call_args_types(args, tool_schemas[func_name])
1320
+
1321
+ # 解码工具ID和thoughtSignature
1322
+ encoded_id = tool_call.get("id", "")
1323
+ original_id, signature = decode_tool_id_and_signature(encoded_id)
1324
+
1325
+ # 构建functionCall part
1326
+ function_call_part = {
1327
+ "functionCall": {
1328
+ "id": original_id,
1329
+ "name": func_name,
1330
+ "args": args
1331
+ }
1332
+ }
1333
+
1334
+ # OpenAI/RooCode 中转可能会改写或截断 tool_call_id,真实签名回传后容易触发
1335
+ # Corrupted thought signature。工具调用使用官方跳过校验占位符更稳。
1336
+ function_call_part["thoughtSignature"] = SKIP_THOUGHT_SIGNATURE_VALIDATOR
1337
+
1338
+ parts.append(function_call_part)
1339
+ except (json.JSONDecodeError, KeyError) as e:
1340
+ log.error(f"Failed to parse tool call: {e}")
1341
+ continue
1342
+
1343
+ if parts:
1344
+ contents.append({"role": role, "parts": parts})
1345
+ continue
1346
+
1347
+ # 处理普通内容
1348
+ if isinstance(content, list):
1349
+ parts = []
1350
+ for part in content:
1351
+ if part.get("type") == "text":
1352
+ parts.append({"text": part.get("text", "")})
1353
+ elif part.get("type") == "image_url":
1354
+ image_url = part.get("image_url", {}).get("url")
1355
+ if image_url:
1356
+ try:
1357
+ mime_type, base64_data = image_url.split(";")
1358
+ _, mime_type = mime_type.split(":")
1359
+ _, base64_data = base64_data.split(",")
1360
+ parts.append({
1361
+ "inlineData": {
1362
+ "mimeType": mime_type,
1363
+ "data": base64_data,
1364
+ }
1365
+ })
1366
+ except ValueError:
1367
+ continue
1368
+ if parts:
1369
+ contents.append({"role": role, "parts": parts})
1370
+ elif content:
1371
+ contents.append({"role": role, "parts": [{"text": content}]})
1372
+
1373
+ # 循环结束后,flush 剩余的 tool parts(如果消息列表以 tool 消息结尾)
1374
+ flush_pending_tool_parts()
1375
+ _sanitize_openai_roundtrip_signatures(contents)
1376
+
1377
+ # 构建生成配置
1378
+ generation_config = {}
1379
+ model = openai_request.get("model", "")
1380
+
1381
+ # 基础参数映射
1382
+ if "temperature" in openai_request:
1383
+ generation_config["temperature"] = openai_request["temperature"]
1384
+ if "top_p" in openai_request:
1385
+ generation_config["topP"] = openai_request["top_p"]
1386
+ if "top_k" in openai_request:
1387
+ generation_config["topK"] = openai_request["top_k"]
1388
+ if "max_tokens" in openai_request or "max_completion_tokens" in openai_request:
1389
+ # max_completion_tokens 优先于 max_tokens
1390
+ max_tokens = openai_request.get("max_completion_tokens") or openai_request.get("max_tokens")
1391
+ generation_config["maxOutputTokens"] = max_tokens
1392
+ if "stop" in openai_request:
1393
+ stop = openai_request["stop"]
1394
+ generation_config["stopSequences"] = [stop] if isinstance(stop, str) else stop
1395
+ if "frequency_penalty" in openai_request:
1396
+ generation_config["frequencyPenalty"] = openai_request["frequency_penalty"]
1397
+ if "presence_penalty" in openai_request:
1398
+ generation_config["presencePenalty"] = openai_request["presence_penalty"]
1399
+ if "n" in openai_request:
1400
+ generation_config["candidateCount"] = openai_request["n"]
1401
+ if "seed" in openai_request:
1402
+ generation_config["seed"] = openai_request["seed"]
1403
+
1404
+ # 处理 response_format
1405
+ if "response_format" in openai_request and openai_request["response_format"]:
1406
+ response_format = openai_request["response_format"]
1407
+ format_type = response_format.get("type")
1408
+
1409
+ if format_type == "json_schema":
1410
+ # JSON Schema 模式
1411
+ if "json_schema" in response_format and "schema" in response_format["json_schema"]:
1412
+ schema = response_format["json_schema"]["schema"]
1413
+ # 清理 schema
1414
+ generation_config["responseSchema"] = _clean_schema_for_gemini(schema)
1415
+ generation_config["responseMimeType"] = "application/json"
1416
+ elif format_type == "json_object":
1417
+ # JSON Object 模式
1418
+ generation_config["responseMimeType"] = "application/json"
1419
+ elif format_type == "text":
1420
+ # Text 模式
1421
+ generation_config["responseMimeType"] = "text/plain"
1422
+
1423
+ # 如果contents为空,添加默认用户消息
1424
+ if not contents:
1425
+ contents.append({"role": "user", "parts": [{"text": "请根据系统指令回答。"}]})
1426
+
1427
+ # 构建基础请求
1428
+ gemini_request = {
1429
+ "contents": contents,
1430
+ "generationConfig": generation_config
1431
+ }
1432
+
1433
+ # 如果 merge_system_messages 已经添加了 systemInstruction,使用它
1434
+ if "systemInstruction" in openai_request:
1435
+ gemini_request["systemInstruction"] = openai_request["systemInstruction"]
1436
+
1437
+ # 处理工具 - 传递 model 参数以便根据模型类型选择清理策略
1438
+ model = openai_request.get("model", "")
1439
+ if "tools" in openai_request and openai_request["tools"]:
1440
+ gemini_request["tools"] = convert_openai_tools_to_gemini(openai_request["tools"], model)
1441
+
1442
+ # 处理tool_choice
1443
+ if "tool_choice" in openai_request and openai_request["tool_choice"]:
1444
+ gemini_request["toolConfig"] = convert_tool_choice_to_tool_config(openai_request["tool_choice"])
1445
+
1446
+ # 透传图片生成的 size 参数(如 "1024x1536")
1447
+ if "size" in openai_request and openai_request["size"]:
1448
+ gemini_request["size"] = openai_request["size"]
1449
+
1450
+ return gemini_request
1451
+
1452
+
1453
+ def convert_gemini_to_openai_response(
1454
+ gemini_response: Union[Dict[str, Any], Any],
1455
+ model: str,
1456
+ status_code: int = 200
1457
+ ) -> Dict[str, Any]:
1458
+ """
1459
+ 将 Gemini 格式非流式响应转换为 OpenAI 格式非流式响应
1460
+
1461
+ 注意: 如果收到的不是 200 开头的响应,不做任何处理,直接转发原始响应
1462
+
1463
+ Args:
1464
+ gemini_response: Gemini 格式的响应体 (字典或响应对象)
1465
+ model: 模型名称
1466
+ status_code: HTTP 状态码 (默认 200)
1467
+
1468
+ Returns:
1469
+ OpenAI 格式的响应体字典,或原始响应 (如果状态码不是 2xx)
1470
+ """
1471
+ # 非 2xx 状态码直接返回原始响应
1472
+ if not (200 <= status_code < 300):
1473
+ if isinstance(gemini_response, dict):
1474
+ return gemini_response
1475
+ else:
1476
+ # 如果是响应对象,尝试解析为字典
1477
+ try:
1478
+ if hasattr(gemini_response, "json"):
1479
+ return gemini_response.json()
1480
+ elif hasattr(gemini_response, "body"):
1481
+ body = gemini_response.body
1482
+ if isinstance(body, bytes):
1483
+ return json.loads(body.decode())
1484
+ return json.loads(str(body))
1485
+ else:
1486
+ return {"error": str(gemini_response)}
1487
+ except Exception:
1488
+ return {"error": str(gemini_response)}
1489
+
1490
+ # 确保是字典格式
1491
+ if not isinstance(gemini_response, dict):
1492
+ try:
1493
+ if hasattr(gemini_response, "json"):
1494
+ gemini_response = gemini_response.json()
1495
+ elif hasattr(gemini_response, "body"):
1496
+ body = gemini_response.body
1497
+ if isinstance(body, bytes):
1498
+ gemini_response = json.loads(body.decode())
1499
+ else:
1500
+ gemini_response = json.loads(str(body))
1501
+ else:
1502
+ gemini_response = json.loads(str(gemini_response))
1503
+ except Exception:
1504
+ return {"error": "Invalid response format"}
1505
+
1506
+ # 处理 GeminiCLI 的 response 包装格式
1507
+ if "response" in gemini_response:
1508
+ gemini_response = gemini_response["response"]
1509
+
1510
+ # 转换为 OpenAI 格式
1511
+ choices = []
1512
+
1513
+ for candidate in gemini_response.get("candidates", []):
1514
+ role = candidate.get("content", {}).get("role", "assistant")
1515
+
1516
+ # 将Gemini角色映射回OpenAI角色
1517
+ if role == "model":
1518
+ role = "assistant"
1519
+
1520
+ # 提取并分离thinking tokens和常规内容
1521
+ parts = candidate.get("content", {}).get("parts", [])
1522
+
1523
+ # 提取工具调用和文本内容
1524
+ tool_calls, text_content = extract_tool_calls_from_parts(parts)
1525
+
1526
+ # 提取多种类型的内容
1527
+ content_parts = []
1528
+ reasoning_parts = []
1529
+
1530
+ for part in parts:
1531
+ # 处理 executableCode(代码生成)
1532
+ if "executableCode" in part:
1533
+ exec_code = part["executableCode"]
1534
+ lang = exec_code.get("language", "python").lower()
1535
+ code = exec_code.get("code", "")
1536
+ # 添加代码块(前后加换行符确保 Markdown 渲染正确)
1537
+ content_parts.append(f"\n```{lang}\n{code}\n```\n")
1538
+
1539
+ # 处理 codeExecutionResult(代码执行结果)
1540
+ elif "codeExecutionResult" in part:
1541
+ result = part["codeExecutionResult"]
1542
+ outcome = result.get("outcome")
1543
+ output = result.get("output", "")
1544
+
1545
+ if output:
1546
+ label = "output" if outcome == "OUTCOME_OK" else "error"
1547
+ content_parts.append(f"\n```{label}\n{output}\n```\n")
1548
+
1549
+ # 处理 thought(思考内容)
1550
+ elif (
1551
+ part.get("thought", False)
1552
+ and "text" in part
1553
+ and not is_skip_thought_signature_placeholder(part)
1554
+ ):
1555
+ reasoning_parts.append(part["text"])
1556
+
1557
+ # 处理普通文本(非思考内容)
1558
+ elif "text" in part and not part.get("thought", False):
1559
+ # 这部分已经在 extract_tool_calls_from_parts 中处理
1560
+ pass
1561
+
1562
+ # 处理 inlineData(图片)
1563
+ elif "inlineData" in part:
1564
+ inline_data = part["inlineData"]
1565
+ mime_type = inline_data.get("mimeType", "image/png")
1566
+ base64_data = inline_data.get("data", "")
1567
+ # 使用 Markdown 格式
1568
+ content_parts.append(f"![gemini-generated-content](data:{mime_type};base64,{base64_data})")
1569
+
1570
+ # 合并所有内容部分
1571
+ if content_parts:
1572
+ # 使用双换行符连接各部分,确保块之间有间距
1573
+ additional_content = "\n\n".join(content_parts)
1574
+ if text_content:
1575
+ text_content = text_content + "\n\n" + additional_content
1576
+ else:
1577
+ text_content = additional_content
1578
+
1579
+ # 合并 reasoning content
1580
+ reasoning_content = "\n\n".join(reasoning_parts) if reasoning_parts else ""
1581
+
1582
+ # 构建消息对象
1583
+ message = {"role": role}
1584
+
1585
+ # 获取 Gemini 的 finishReason
1586
+ gemini_finish_reason = candidate.get("finishReason")
1587
+
1588
+ # 如果有工具调用
1589
+ if tool_calls:
1590
+ message["tool_calls"] = tool_calls
1591
+ message["content"] = text_content if text_content else None
1592
+ # 只有在正常停止(STOP)时才设为 tool_calls,其他情况保持原始 finish_reason
1593
+ # 这样可以避免在 SAFETY、MAX_TOKENS 等情况下仍然返回 tool_calls 导致循环
1594
+ if gemini_finish_reason == "STOP":
1595
+ finish_reason = "tool_calls"
1596
+ else:
1597
+ finish_reason = _map_finish_reason(gemini_finish_reason)
1598
+ else:
1599
+ message["content"] = text_content
1600
+ finish_reason = _map_finish_reason(gemini_finish_reason)
1601
+
1602
+ # 添加 reasoning content (如果有)
1603
+ if reasoning_content:
1604
+ message["reasoning_content"] = reasoning_content
1605
+
1606
+ choices.append({
1607
+ "index": candidate.get("index", 0),
1608
+ "message": message,
1609
+ "finish_reason": finish_reason,
1610
+ })
1611
+
1612
+ # 转换 usageMetadata
1613
+ usage = _convert_usage_metadata(gemini_response.get("usageMetadata"))
1614
+
1615
+ response_data = {
1616
+ "id": str(uuid.uuid4()),
1617
+ "object": "chat.completion",
1618
+ "created": int(time.time()),
1619
+ "model": model,
1620
+ "choices": choices,
1621
+ }
1622
+
1623
+ if usage:
1624
+ response_data["usage"] = usage
1625
+
1626
+ return response_data
1627
+
1628
+
1629
+ def convert_gemini_to_openai_stream(
1630
+ gemini_stream_chunk: str,
1631
+ model: str,
1632
+ response_id: str,
1633
+ status_code: int = 200
1634
+ ) -> Optional[str]:
1635
+ """
1636
+ 将 Gemini 格式流式响应块转换为 OpenAI SSE 格式流式响应
1637
+
1638
+ 注意: 如果收到的不是 200 开头的响应,不做任何处理,直接转发原始内容
1639
+
1640
+ Args:
1641
+ gemini_stream_chunk: Gemini 格式的流式响应块 (字符串,通常是 "data: {json}" 格式)
1642
+ model: 模型名称
1643
+ response_id: 此流式响应的一致ID
1644
+ status_code: HTTP 状态码 (默认 200)
1645
+
1646
+ Returns:
1647
+ OpenAI SSE 格式的响应字符串 (如 "data: {json}\n\n"),
1648
+ 或原始内容 (如果状态码不是 2xx),
1649
+ 或 None (如果解析失败)
1650
+ """
1651
+ # 非 2xx 状态码直接返回原始内容
1652
+ if not (200 <= status_code < 300):
1653
+ return gemini_stream_chunk
1654
+
1655
+ # 解析 Gemini 流式块
1656
+ try:
1657
+ # 去除 "data: " 前缀
1658
+ if isinstance(gemini_stream_chunk, bytes):
1659
+ if gemini_stream_chunk.startswith(b"data: "):
1660
+ payload_str = gemini_stream_chunk[len(b"data: "):].strip().decode("utf-8")
1661
+ else:
1662
+ payload_str = gemini_stream_chunk.strip().decode("utf-8")
1663
+ else:
1664
+ if gemini_stream_chunk.startswith("data: "):
1665
+ payload_str = gemini_stream_chunk[len("data: "):].strip()
1666
+ else:
1667
+ payload_str = gemini_stream_chunk.strip()
1668
+
1669
+ # 跳过空块
1670
+ if not payload_str:
1671
+ return None
1672
+
1673
+ # 解析 JSON
1674
+ gemini_chunk = json.loads(payload_str)
1675
+ except (json.JSONDecodeError, UnicodeDecodeError):
1676
+ # 解析失败,跳过此块
1677
+ return None
1678
+
1679
+ # 处理 GeminiCLI 的 response 包装格式
1680
+ if "response" in gemini_chunk:
1681
+ gemini_response = gemini_chunk["response"]
1682
+ else:
1683
+ gemini_response = gemini_chunk
1684
+
1685
+ # 转换为 OpenAI 流式格式
1686
+ choices = []
1687
+
1688
+ for candidate in gemini_response.get("candidates", []):
1689
+ role = candidate.get("content", {}).get("role", "assistant")
1690
+
1691
+ # 将Gemini角色映射回OpenAI角色
1692
+ if role == "model":
1693
+ role = "assistant"
1694
+
1695
+ # 提取并分离thinking tokens和常规内容
1696
+ parts = candidate.get("content", {}).get("parts", [])
1697
+
1698
+ # 提取工具调用和文本内容 (流式需要 index)
1699
+ tool_calls, text_content = extract_tool_calls_from_parts(parts, is_streaming=True)
1700
+
1701
+ # 提取多种类型的内容
1702
+ content_parts = []
1703
+ reasoning_parts = []
1704
+
1705
+ for part in parts:
1706
+ # 处理 executableCode(代码生成)
1707
+ if "executableCode" in part:
1708
+ exec_code = part["executableCode"]
1709
+ lang = exec_code.get("language", "python").lower()
1710
+ code = exec_code.get("code", "")
1711
+ content_parts.append(f"\n```{lang}\n{code}\n```\n")
1712
+
1713
+ # 处理 codeExecutionResult(代码执行结果)
1714
+ elif "codeExecutionResult" in part:
1715
+ result = part["codeExecutionResult"]
1716
+ outcome = result.get("outcome")
1717
+ output = result.get("output", "")
1718
+
1719
+ if output:
1720
+ label = "output" if outcome == "OUTCOME_OK" else "error"
1721
+ content_parts.append(f"\n```{label}\n{output}\n```\n")
1722
+
1723
+ # 处理 thought(思考内容)
1724
+ elif (
1725
+ part.get("thought", False)
1726
+ and "text" in part
1727
+ and not is_skip_thought_signature_placeholder(part)
1728
+ ):
1729
+ reasoning_parts.append(part["text"])
1730
+
1731
+ # 处理普通文本(非思考内容)
1732
+ elif "text" in part and not part.get("thought", False):
1733
+ # 这部分已经在 extract_tool_calls_from_parts 中处理
1734
+ pass
1735
+
1736
+ # 处理 inlineData(图片)
1737
+ elif "inlineData" in part:
1738
+ inline_data = part["inlineData"]
1739
+ mime_type = inline_data.get("mimeType", "image/png")
1740
+ base64_data = inline_data.get("data", "")
1741
+ content_parts.append(f"![gemini-generated-content](data:{mime_type};base64,{base64_data})")
1742
+
1743
+ # 合并所有内容部分
1744
+ if content_parts:
1745
+ additional_content = "\n\n".join(content_parts)
1746
+ if text_content:
1747
+ text_content = text_content + "\n\n" + additional_content
1748
+ else:
1749
+ text_content = additional_content
1750
+
1751
+ # 合并 reasoning content
1752
+ reasoning_content = "\n\n".join(reasoning_parts) if reasoning_parts else ""
1753
+
1754
+ # 构建 delta 对象
1755
+ delta = {}
1756
+
1757
+ if tool_calls:
1758
+ delta["tool_calls"] = tool_calls
1759
+ if text_content:
1760
+ delta["content"] = text_content
1761
+ elif text_content:
1762
+ delta["content"] = text_content
1763
+
1764
+ if reasoning_content:
1765
+ delta["reasoning_content"] = reasoning_content
1766
+
1767
+ # 获取 Gemini 的 finishReason
1768
+ gemini_finish_reason = candidate.get("finishReason")
1769
+ finish_reason = _map_finish_reason(gemini_finish_reason)
1770
+
1771
+ # 只有在正常停止(STOP)且有工具调用时才设为 tool_calls
1772
+ # 避免在 SAFETY、MAX_TOKENS 等情况下仍然返回 tool_calls 导致循环
1773
+ if tool_calls and gemini_finish_reason == "STOP":
1774
+ finish_reason = "tool_calls"
1775
+
1776
+ choices.append({
1777
+ "index": candidate.get("index", 0),
1778
+ "delta": delta,
1779
+ "finish_reason": finish_reason,
1780
+ })
1781
+
1782
+ # 转换 usageMetadata (只在流结束时存在)
1783
+ usage = _convert_usage_metadata(gemini_response.get("usageMetadata"))
1784
+
1785
+ # 构建 OpenAI 流式响应
1786
+ response_data = {
1787
+ "id": response_id,
1788
+ "object": "chat.completion.chunk",
1789
+ "created": int(time.time()),
1790
+ "model": model,
1791
+ "choices": choices,
1792
+ }
1793
+
1794
+ # 只在有 usage 数据且有 finish_reason 时添加 usage
1795
+ if usage:
1796
+ has_finish_reason = any(choice.get("finish_reason") for choice in choices)
1797
+ if has_finish_reason:
1798
+ response_data["usage"] = usage
1799
+
1800
+ # 转换为 SSE 格式: "data: {json}\n\n"
1801
+ return f"data: {json.dumps(response_data)}\n\n"
src/converter/thoughtSignature_fix.py ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ thoughtSignature 处理公共模块
3
+
4
+ 提供统一的 thoughtSignature 编码/解码功能,用于在工具调用ID中保留签名信息。
5
+ 这使得签名能够在客户端往返传输中保留,即使客户端会删除自定义字段。
6
+ """
7
+
8
+ from typing import Any, Mapping, Optional, Tuple
9
+
10
+ # 在工具调用ID中嵌入thoughtSignature的分隔符
11
+ # 这使得签名能够在客户端往返传输中保留,即使客户端会删除自定义字段
12
+ THOUGHT_SIGNATURE_SEPARATOR = "__thought__"
13
+ SKIP_THOUGHT_SIGNATURE_VALIDATOR = "skip_thought_signature_validator"
14
+ SKIP_THOUGHT_SIGNATURE_PLACEHOLDER_TEXT = "..."
15
+
16
+
17
+ def is_internal_placeholder_text(text: Any) -> bool:
18
+ if not isinstance(text, str):
19
+ return False
20
+ return text.strip() in (SKIP_THOUGHT_SIGNATURE_PLACEHOLDER_TEXT, "…")
21
+
22
+
23
+ def is_skip_thought_signature_placeholder(part: Mapping[str, Any]) -> bool:
24
+ """Return True for the internal placeholder that should not reach clients."""
25
+ if not isinstance(part, Mapping):
26
+ return False
27
+ if part.get("thoughtSignature") != SKIP_THOUGHT_SIGNATURE_VALIDATOR:
28
+ return False
29
+ if "functionCall" in part or "function_call" in part or "functionResponse" in part:
30
+ return False
31
+ return is_internal_placeholder_text(part.get("text"))
32
+
33
+
34
+ def encode_tool_id_with_signature(tool_id: str, signature: Optional[str]) -> str:
35
+ """
36
+ 将 thoughtSignature 编码到工具调用ID中,以便往返保留。
37
+
38
+ Args:
39
+ tool_id: 原始工具调用ID
40
+ signature: thoughtSignature(可选)
41
+
42
+ Returns:
43
+ 编码后的工具调用ID
44
+
45
+ Examples:
46
+ >>> encode_tool_id_with_signature("call_123", "abc")
47
+ 'call_123__thought__abc'
48
+ >>> encode_tool_id_with_signature("call_123", None)
49
+ 'call_123'
50
+ """
51
+ if not signature:
52
+ return tool_id
53
+ return f"{tool_id}{THOUGHT_SIGNATURE_SEPARATOR}{signature}"
54
+
55
+
56
+ def decode_tool_id_and_signature(encoded_id: str) -> Tuple[str, Optional[str]]:
57
+ """
58
+ 从编码的ID中提取原始工具ID和thoughtSignature。
59
+
60
+ Args:
61
+ encoded_id: 编码的工具调用ID
62
+
63
+ Returns:
64
+ (原始工具ID, thoughtSignature) 元组
65
+
66
+ Examples:
67
+ >>> decode_tool_id_and_signature("call_123__thought__abc")
68
+ ('call_123', 'abc')
69
+ >>> decode_tool_id_and_signature("call_123")
70
+ ('call_123', None)
71
+ """
72
+ if not encoded_id or THOUGHT_SIGNATURE_SEPARATOR not in encoded_id:
73
+ return encoded_id, None
74
+ parts = encoded_id.split(THOUGHT_SIGNATURE_SEPARATOR, 1)
75
+ return parts[0], parts[1] if len(parts) == 2 else None
src/converter/utils.py ADDED
@@ -0,0 +1,247 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Any, Dict
2
+
3
+ from src.converter.thoughtSignature_fix import (
4
+ is_internal_placeholder_text,
5
+ is_skip_thought_signature_placeholder,
6
+ )
7
+
8
+
9
+ def extract_content_and_reasoning(parts: list) -> tuple:
10
+ """从Gemini响应部件中提取内容和推理内容
11
+
12
+ Args:
13
+ parts: Gemini 响应中的 parts 列表
14
+
15
+ Returns:
16
+ (content, reasoning_content, images): 文本内容、推理内容和图片数据的元组
17
+ - content: 文本内容字符串
18
+ - reasoning_content: 推理内容字符串
19
+ - images: 图片数据列表,每个元素格式为:
20
+ {
21
+ "type": "image_url",
22
+ "image_url": {
23
+ "url": "data:{mime_type};base64,{base64_data}"
24
+ }
25
+ }
26
+ """
27
+ content = ""
28
+ reasoning_content = ""
29
+ images = []
30
+
31
+ for part in parts:
32
+ if is_skip_thought_signature_placeholder(part):
33
+ continue
34
+
35
+ # 提取文本内容
36
+ text = part.get("text", "")
37
+ if is_internal_placeholder_text(text):
38
+ continue
39
+ if text:
40
+ if part.get("thought", False):
41
+ reasoning_content += text
42
+ else:
43
+ content += text
44
+
45
+ # 提取图片数据
46
+ if "inlineData" in part:
47
+ inline_data = part["inlineData"]
48
+ mime_type = inline_data.get("mimeType", "image/png")
49
+ base64_data = inline_data.get("data", "")
50
+ images.append({
51
+ "type": "image_url",
52
+ "image_url": {
53
+ "url": f"data:{mime_type};base64,{base64_data}"
54
+ }
55
+ })
56
+
57
+ return content, reasoning_content, images
58
+
59
+
60
+ async def merge_system_messages(request_body: Dict[str, Any]) -> Dict[str, Any]:
61
+ """
62
+ 根据兼容性模式处理请求体中的system消息
63
+
64
+ - 兼容性模式关闭(False):将连续的system消息合并为systemInstruction
65
+ - 兼容性模式开启(True):将所有system消息转换为user消息
66
+
67
+ Args:
68
+ request_body: OpenAI或Claude格式的请求体,包含messages字段
69
+
70
+ Returns:
71
+ 处理后的请求体
72
+
73
+ Example (兼容性模式关闭):
74
+ 输入:
75
+ {
76
+ "messages": [
77
+ {"role": "system", "content": "You are a helpful assistant."},
78
+ {"role": "system", "content": "You are an expert in Python."},
79
+ {"role": "user", "content": "Hello"}
80
+ ]
81
+ }
82
+
83
+ 输出:
84
+ {
85
+ "systemInstruction": {
86
+ "parts": [
87
+ {"text": "You are a helpful assistant."},
88
+ {"text": "You are an expert in Python."}
89
+ ]
90
+ },
91
+ "messages": [
92
+ {"role": "user", "content": "Hello"}
93
+ ]
94
+ }
95
+
96
+ Example (兼容性模式开启):
97
+ 输入:
98
+ {
99
+ "messages": [
100
+ {"role": "system", "content": "You are a helpful assistant."},
101
+ {"role": "user", "content": "Hello"}
102
+ ]
103
+ }
104
+
105
+ 输出:
106
+ {
107
+ "messages": [
108
+ {"role": "user", "content": "You are a helpful assistant."},
109
+ {"role": "user", "content": "Hello"}
110
+ ]
111
+ }
112
+
113
+ Example (Anthropic格式,兼容性模式关闭):
114
+ 输入:
115
+ {
116
+ "system": "You are a helpful assistant.",
117
+ "messages": [
118
+ {"role": "user", "content": "Hello"}
119
+ ]
120
+ }
121
+
122
+ 输出:
123
+ {
124
+ "systemInstruction": {
125
+ "parts": [
126
+ {"text": "You are a helpful assistant."}
127
+ ]
128
+ },
129
+ "messages": [
130
+ {"role": "user", "content": "Hello"}
131
+ ]
132
+ }
133
+ """
134
+ from config import get_compatibility_mode_enabled
135
+
136
+ compatibility_mode = await get_compatibility_mode_enabled()
137
+
138
+ # 处理 Anthropic 格式的顶层 system 参数
139
+ # Anthropic API 规范: system 是顶层参数,不在 messages 中
140
+ system_content = request_body.get("system")
141
+ if system_content:
142
+ system_parts = []
143
+
144
+ if isinstance(system_content, str):
145
+ if system_content.strip():
146
+ system_parts.append({"text": system_content})
147
+ elif isinstance(system_content, list):
148
+ # system 可以是包含多个块的列表
149
+ for item in system_content:
150
+ if isinstance(item, dict):
151
+ if item.get("type") == "text" and item.get("text", "").strip():
152
+ system_parts.append({"text": item["text"]})
153
+ elif isinstance(item, str) and item.strip():
154
+ system_parts.append({"text": item})
155
+
156
+ if system_parts:
157
+ if compatibility_mode:
158
+ # 兼容性模式:将 system 转换为 user 消息插入到 messages 开头
159
+ user_system_message = {
160
+ "role": "user",
161
+ "content": system_content if isinstance(system_content, str) else
162
+ "\n".join(part["text"] for part in system_parts)
163
+ }
164
+ messages = request_body.get("messages", [])
165
+ request_body = request_body.copy()
166
+ request_body["messages"] = [user_system_message] + messages
167
+ else:
168
+ # 非兼容性模式:添加为 systemInstruction
169
+ request_body = request_body.copy()
170
+ request_body["systemInstruction"] = {"parts": system_parts}
171
+
172
+ messages = request_body.get("messages", [])
173
+ if not messages:
174
+ return request_body
175
+
176
+ compatibility_mode = await get_compatibility_mode_enabled()
177
+
178
+ if compatibility_mode:
179
+ # 兼容性模式开启:将所有system消息转换为user消息
180
+ converted_messages = []
181
+ for message in messages:
182
+ if message.get("role") == "system":
183
+ # 创建新的消息对象,将role改为user
184
+ converted_message = message.copy()
185
+ converted_message["role"] = "user"
186
+ converted_messages.append(converted_message)
187
+ else:
188
+ converted_messages.append(message)
189
+
190
+ result = request_body.copy()
191
+ result["messages"] = converted_messages
192
+ return result
193
+ else:
194
+ # 兼容性模式关闭:提取连续的system消息合并为systemInstruction
195
+ system_parts = []
196
+
197
+ # 如果已经从顶层 system 参数创建了 systemInstruction,获取现有的 parts
198
+ if "systemInstruction" in request_body:
199
+ existing_instruction = request_body.get("systemInstruction", {})
200
+ if isinstance(existing_instruction, dict):
201
+ system_parts = existing_instruction.get("parts", []).copy()
202
+
203
+ remaining_messages = []
204
+ collecting_system = True
205
+
206
+ for message in messages:
207
+ role = message.get("role", "")
208
+ content = message.get("content", "")
209
+
210
+ if role == "system" and collecting_system:
211
+ # 提取system消息的文本内容
212
+ if isinstance(content, str):
213
+ if content.strip():
214
+ system_parts.append({"text": content})
215
+ elif isinstance(content, list):
216
+ # 处理列表格式的content
217
+ for item in content:
218
+ if isinstance(item, dict):
219
+ if item.get("type") == "text" and item.get("text", "").strip():
220
+ system_parts.append({"text": item["text"]})
221
+ elif isinstance(item, str) and item.strip():
222
+ system_parts.append({"text": item})
223
+ else:
224
+ # 遇到非system消息,停止收集
225
+ collecting_system = False
226
+ if role == "system":
227
+ # 将后续的system消息转换为user消息
228
+ converted_message = message.copy()
229
+ converted_message["role"] = "user"
230
+ remaining_messages.append(converted_message)
231
+ else:
232
+ remaining_messages.append(message)
233
+
234
+ # 如果没有找到任何system消息(包括顶层参数和messages中的),返回原始请求体
235
+ if not system_parts:
236
+ return request_body
237
+
238
+ # 构建新的请求体
239
+ result = request_body.copy()
240
+
241
+ # 添加或更新systemInstruction
242
+ result["systemInstruction"] = {"parts": system_parts}
243
+
244
+ # 更新messages列表(移除已处理的system消息)
245
+ result["messages"] = remaining_messages
246
+
247
+ return result
src/credential_manager.py ADDED
@@ -0,0 +1,510 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 凭证管理器
3
+ """
4
+
5
+ import asyncio
6
+ import time
7
+ from datetime import datetime, timezone
8
+ from typing import Any, Dict, List, Optional, Tuple
9
+
10
+ from log import log
11
+
12
+ from src.google_oauth_api import Credentials
13
+ from src.storage_adapter import get_storage_adapter
14
+
15
+ class CredentialManager:
16
+ """
17
+ 统一凭证管理器
18
+ 所有存储操作通过storage_adapter进行
19
+ """
20
+
21
+ def __init__(self):
22
+ # 核心状态
23
+ self._initialized = False
24
+ self._storage_adapter = None
25
+
26
+ # 并发控制(简化)
27
+ # 后端数据库自行处理并发,credential_manager 不再使用本地锁
28
+
29
+ async def _ensure_initialized(self):
30
+ """确保管理器已初始化(内部使用)"""
31
+ if not self._initialized or self._storage_adapter is None:
32
+ await self.initialize()
33
+
34
+ async def initialize(self):
35
+ """初始化凭证管理器"""
36
+ if self._initialized and self._storage_adapter is not None:
37
+ return
38
+
39
+ # 初始化统一存储适配器
40
+ self._storage_adapter = await get_storage_adapter()
41
+ self._initialized = True
42
+
43
+ async def close(self):
44
+ """清理资源"""
45
+ log.debug("Closing credential manager...")
46
+ self._initialized = False
47
+ log.debug("Credential manager closed")
48
+
49
+ async def get_valid_credential(
50
+ self, mode: str = "geminicli", model_name: Optional[str] = None
51
+ ) -> Optional[Tuple[str, Dict[str, Any]]]:
52
+ """
53
+ 获取有效的凭证 - 随机负载均衡版
54
+ 每次随机选择一个可用的凭证(未禁用、未冷却、符合preview要求)
55
+ 如果刷新失败会自动禁用失效凭证并重试获取下一个可用凭证
56
+
57
+ Args:
58
+ mode: 凭证模式 ("geminicli" 或 "antigravity")
59
+ model_name: 完整模型名,用于模型级冷却检查和preview筛选
60
+ - geminicli: 完整模型名
61
+ - 包含 "preview" 的模型只能使用 preview=True 的凭证
62
+ - 不包含 "preview" 的模型优先使用 preview=False 的凭证
63
+ - antigravity: 完整模型名(如 "gemini-2.0-flash-exp")
64
+ """
65
+ await self._ensure_initialized()
66
+
67
+ # 最多重试3次
68
+ max_retries = 3
69
+ for attempt in range(max_retries):
70
+ result = await self._storage_adapter._backend.get_next_available_credential(
71
+ mode=mode, model_name=model_name
72
+ )
73
+
74
+ # 如果没有可用凭证,直接返回None
75
+ if not result:
76
+ if attempt == 0:
77
+ log.warning(f"没有可用凭证 (mode={mode}, model_name={model_name})")
78
+ return None
79
+
80
+ filename, credential_data = result
81
+
82
+ # Token 刷新检查
83
+ if await self._should_refresh_token(credential_data):
84
+ log.debug(f"Token需要刷新 - 文件: {filename} (mode={mode})")
85
+ refreshed_data = await self._refresh_token(credential_data, filename, mode=mode)
86
+ if refreshed_data:
87
+ # 刷新成功,返回凭证
88
+ credential_data = refreshed_data
89
+ log.debug(f"Token刷新成功: {filename} (mode={mode})")
90
+ return filename, credential_data
91
+ else:
92
+ # 刷新失败(_refresh_token内部已自动禁用失效凭证)
93
+ log.warning(f"Token刷新失败,尝试获取下一个凭证: {filename} (mode={mode}, attempt={attempt+1}/{max_retries})")
94
+ # 继续循环,尝试获取下一个可用凭证
95
+ continue
96
+ else:
97
+ # Token有效,直接返回
98
+ return filename, credential_data
99
+
100
+ # 重试次数用尽
101
+ log.error(f"重试{max_retries}次后仍无可用凭证 (mode={mode}, model_name={model_name})")
102
+ return None
103
+
104
+ async def add_credential(self, credential_name: str, credential_data: Dict[str, Any]):
105
+ """
106
+ 新增或更新一个凭证
107
+ 存储层会自动处理轮换顺序
108
+ """
109
+ await self._ensure_initialized()
110
+ await self._storage_adapter.store_credential(credential_name, credential_data)
111
+ log.info(f"Credential added/updated: {credential_name}")
112
+
113
+ async def add_antigravity_credential(self, credential_name: str, credential_data: Dict[str, Any]):
114
+ """
115
+ 新增或更新一个Antigravity凭证
116
+ 存储层会自动处理轮换顺序
117
+ """
118
+ await self._ensure_initialized()
119
+ await self._storage_adapter.store_credential(credential_name, credential_data, mode="antigravity")
120
+ log.info(f"Antigravity credential added/updated: {credential_name}")
121
+
122
+ async def remove_credential(self, credential_name: str, mode: str = "geminicli") -> bool:
123
+ """删除一个凭证"""
124
+ await self._ensure_initialized()
125
+ try:
126
+ await self._storage_adapter.delete_credential(credential_name, mode=mode)
127
+ log.info(f"Credential removed: {credential_name} (mode={mode})")
128
+ return True
129
+ except Exception as e:
130
+ log.error(f"Error removing credential {credential_name}: {e}")
131
+ return False
132
+
133
+ async def update_credential_state(self, credential_name: str, state_updates: Dict[str, Any], mode: str = "geminicli"):
134
+ """更新凭证状态"""
135
+ log.debug(f"[CredMgr] update_credential_state 开始: credential_name={credential_name}, state_updates={state_updates}, mode={mode}")
136
+ log.debug(f"[CredMgr] 调用 _ensure_initialized...")
137
+ await self._ensure_initialized()
138
+ log.debug(f"[CredMgr] _ensure_initialized 完成")
139
+ try:
140
+ log.debug(f"[CredMgr] 调用 storage_adapter.update_credential_state...")
141
+ success = await self._storage_adapter.update_credential_state(
142
+ credential_name, state_updates, mode=mode
143
+ )
144
+ log.debug(f"[CredMgr] storage_adapter.update_credential_state 返回: {success}")
145
+ if success:
146
+ log.debug(f"Updated credential state: {credential_name} (mode={mode})")
147
+ else:
148
+ log.warning(f"Failed to update credential state: {credential_name} (mode={mode})")
149
+ return success
150
+ except Exception as e:
151
+ log.error(f"Error updating credential state {credential_name}: {e}")
152
+ return False
153
+
154
+ async def set_cred_disabled(self, credential_name: str, disabled: bool, mode: str = "geminicli"):
155
+ """设置凭证的启用/禁用状态"""
156
+ try:
157
+ log.info(f"[CredMgr] set_cred_disabled 开始: credential_name={credential_name}, disabled={disabled}, mode={mode}")
158
+ success = await self.update_credential_state(
159
+ credential_name, {"disabled": disabled}, mode=mode
160
+ )
161
+ log.info(f"[CredMgr] update_credential_state 返回: success={success}")
162
+ if success:
163
+ action = "disabled" if disabled else "enabled"
164
+ log.info(f"Credential {action}: {credential_name} (mode={mode})")
165
+ else:
166
+ log.warning(f"[CredMgr] 设置禁用状态失败: credential_name={credential_name}, disabled={disabled}")
167
+ return success
168
+ except Exception as e:
169
+ log.error(f"Error setting credential disabled state {credential_name}: {e}")
170
+ return False
171
+
172
+ async def get_creds_status(self) -> Dict[str, Dict[str, Any]]:
173
+ """获取所有凭证的状态"""
174
+ await self._ensure_initialized()
175
+ try:
176
+ return await self._storage_adapter.get_all_credential_states()
177
+ except Exception as e:
178
+ log.error(f"Error getting credential statuses: {e}")
179
+ return {}
180
+
181
+ async def get_creds_summary(self) -> List[Dict[str, Any]]:
182
+ """
183
+ 获取所有凭证的摘要信息(轻量级,不包含完整凭证数据)
184
+ 使用后端的高性能查询
185
+ """
186
+ await self._ensure_initialized()
187
+ try:
188
+ return await self._storage_adapter._backend.get_credentials_summary()
189
+ except Exception as e:
190
+ log.error(f"Error getting credentials summary: {e}")
191
+ return []
192
+
193
+ async def get_or_fetch_user_email(self, credential_name: str, mode: str = "geminicli") -> Optional[str]:
194
+ """获取或获取用户邮箱地址"""
195
+ try:
196
+ # 确保已初始化
197
+ await self._ensure_initialized()
198
+
199
+ # 从状态中获取缓存的邮箱
200
+ state = await self._storage_adapter.get_credential_state(credential_name, mode=mode)
201
+ cached_email = state.get("user_email") if state else None
202
+
203
+ if cached_email:
204
+ return cached_email
205
+
206
+ # 如果没有缓存,从凭证数据获取
207
+ credential_data = await self._storage_adapter.get_credential(credential_name, mode=mode)
208
+ if not credential_data:
209
+ return None
210
+
211
+ # 创建凭证对象并自动刷新 token
212
+ from .google_oauth_api import Credentials, get_user_email
213
+
214
+ credentials = Credentials.from_dict(credential_data)
215
+ if not credentials:
216
+ return None
217
+
218
+ # 自动刷新 token(如果需要)
219
+ token_refreshed = await credentials.refresh_if_needed()
220
+
221
+ # 如果 token 被刷新了,更新存储
222
+ if token_refreshed:
223
+ log.info(f"Token已自动刷新: {credential_name} (mode={mode})")
224
+ updated_data = credentials.to_dict()
225
+ await self._storage_adapter.store_credential(credential_name, updated_data, mode=mode)
226
+
227
+ # 获取邮箱
228
+ email = await get_user_email(credentials)
229
+
230
+ if email:
231
+ # 缓存邮箱地址
232
+ await self._storage_adapter.update_credential_state(
233
+ credential_name, {"user_email": email}, mode=mode
234
+ )
235
+ return email
236
+
237
+ return None
238
+
239
+ except Exception as e:
240
+ log.error(f"Error fetching user email for {credential_name}: {e}")
241
+ return None
242
+
243
+ async def record_api_call_result(
244
+ self,
245
+ credential_name: str,
246
+ success: bool,
247
+ error_code: Optional[int] = None,
248
+ cooldown_until: Optional[float] = None,
249
+ mode: str = "geminicli",
250
+ model_name: Optional[str] = None,
251
+ error_message: Optional[str] = None
252
+ ):
253
+ """
254
+ 记录API调用结果
255
+
256
+ Args:
257
+ credential_name: 凭证名称
258
+ success: 是否成功
259
+ error_code: 错误码(如果失败)
260
+ cooldown_until: 冷却截止时间戳(Unix时间戳,针对429 QUOTA_EXHAUSTED)
261
+ mode: 凭证模式 ("geminicli" 或 "antigravity")
262
+ model_name: 模型名(用于设置模型级冷却)
263
+ error_message: 错误信息(如果失败)
264
+ """
265
+ await self._ensure_initialized()
266
+ try:
267
+ if success:
268
+ # 条件写入:仅当凭证有错误状态或模型冷却时才写 DB,零内存缓存
269
+ # fire-and-forget,不阻塞请求链路
270
+ asyncio.create_task(
271
+ self._storage_adapter._backend.record_success(
272
+ credential_name, model_name=model_name, mode=mode
273
+ )
274
+ )
275
+
276
+ elif error_code:
277
+ # 记录错误码和错误信息
278
+ error_messages = {}
279
+ if error_message:
280
+ error_messages[str(error_code)] = error_message
281
+
282
+ state_updates = {
283
+ "error_codes": [error_code],
284
+ "error_messages": error_messages,
285
+ }
286
+
287
+ await self.update_credential_state(credential_name, state_updates, mode=mode)
288
+
289
+ # 设置模型级冷却
290
+ if cooldown_until is not None and model_name:
291
+ if hasattr(self._storage_adapter._backend, 'set_model_cooldown'):
292
+ await self._storage_adapter._backend.set_model_cooldown(
293
+ credential_name, model_name, cooldown_until, mode=mode
294
+ )
295
+ log.info(
296
+ f"设置模型级冷却: {credential_name}, model_name={model_name}, "
297
+ f"冷却至: {datetime.fromtimestamp(cooldown_until, timezone.utc).isoformat()}"
298
+ )
299
+
300
+ except Exception as e:
301
+ log.error(f"Error recording API call result for {credential_name}: {e}")
302
+
303
+ async def _should_refresh_token(self, credential_data: Dict[str, Any]) -> bool:
304
+ """检查token是否需要刷新"""
305
+ try:
306
+ # 如果没有access_token或过期时间,需要刷新
307
+ if not credential_data.get("access_token") and not credential_data.get("token"):
308
+ log.debug("没有access_token,需要刷新")
309
+ return True
310
+
311
+ expiry_str = credential_data.get("expiry")
312
+ if not expiry_str:
313
+ log.debug("没有过期时间,需要刷新")
314
+ return True
315
+
316
+ # 解析过期时间
317
+ try:
318
+ if isinstance(expiry_str, str):
319
+ if "+" in expiry_str:
320
+ file_expiry = datetime.fromisoformat(expiry_str)
321
+ elif expiry_str.endswith("Z"):
322
+ file_expiry = datetime.fromisoformat(expiry_str.replace("Z", "+00:00"))
323
+ else:
324
+ file_expiry = datetime.fromisoformat(expiry_str)
325
+ else:
326
+ log.debug("过期时间格式无效,需要刷新")
327
+ return True
328
+
329
+ # 确保时区信息
330
+ if file_expiry.tzinfo is None:
331
+ file_expiry = file_expiry.replace(tzinfo=timezone.utc)
332
+
333
+ # 检查是否还有至少5分钟有效期
334
+ now = datetime.now(timezone.utc)
335
+ time_left = (file_expiry - now).total_seconds()
336
+
337
+ log.debug(
338
+ f"Token时间检查: "
339
+ f"当前UTC时间={now.isoformat()}, "
340
+ f"过期时间={file_expiry.isoformat()}, "
341
+ f"剩余时间={int(time_left/60)}分{int(time_left%60)}秒"
342
+ )
343
+
344
+ if time_left > 300: # 5分钟缓冲
345
+ return False
346
+ else:
347
+ log.debug(f"Token即将过期(剩余{int(time_left/60)}分钟),需要刷新")
348
+ return True
349
+
350
+ except Exception as e:
351
+ log.warning(f"解析过期时间失败: {e},需要刷新")
352
+ return True
353
+
354
+ except Exception as e:
355
+ log.error(f"检查token过期时出错: {e}")
356
+ return True
357
+
358
+ async def _refresh_token(
359
+ self, credential_data: Dict[str, Any], filename: str, mode: str = "geminicli"
360
+ ) -> Optional[Dict[str, Any]]:
361
+ """刷新token并更新存储"""
362
+ await self._ensure_initialized()
363
+ try:
364
+ # 创建Credentials对象
365
+ creds = Credentials.from_dict(credential_data)
366
+
367
+ # 检查是否可以刷新
368
+ if not creds.refresh_token:
369
+ log.error(f"没有refresh_token,无法刷新: {filename} (mode={mode})")
370
+ # 自动禁用没有refresh_token的凭证
371
+ try:
372
+ await self.update_credential_state(filename, {"disabled": True}, mode=mode)
373
+ log.warning(f"凭证已自动禁用(缺少refresh_token): {filename}")
374
+ except Exception as e:
375
+ log.error(f"禁用凭证失败 {filename}: {e}")
376
+ return None
377
+
378
+ # 刷新token
379
+ log.debug(f"正在刷新token: {filename} (mode={mode})")
380
+ await creds.refresh()
381
+
382
+ # 更新凭证数据
383
+ if creds.access_token:
384
+ credential_data["access_token"] = creds.access_token
385
+ # 保持兼容性
386
+ credential_data["token"] = creds.access_token
387
+
388
+ if creds.expires_at:
389
+ credential_data["expiry"] = creds.expires_at.isoformat()
390
+
391
+ # 保存到存储
392
+ await self._storage_adapter.store_credential(filename, credential_data, mode=mode)
393
+ log.info(f"Token刷新成功并已保存: {filename} (mode={mode})")
394
+
395
+ return credential_data
396
+
397
+ except Exception as e:
398
+ error_msg = str(e)
399
+ log.error(f"Token刷新失败 {filename} (mode={mode}): {error_msg}")
400
+
401
+ # 尝试提取HTTP状态码(TokenError可能携带status_code属性)
402
+ status_code = None
403
+ if hasattr(e, 'status_code'):
404
+ status_code = e.status_code
405
+
406
+ # 检查是否是凭证永久失效的错误(只有明确的400/403等才判定为永久失效)
407
+ is_permanent_failure = self._is_permanent_refresh_failure(error_msg, status_code)
408
+
409
+ if is_permanent_failure:
410
+ log.warning(f"检测到凭证永久失效 (HTTP {status_code}): {filename}")
411
+ # 记录失效状态
412
+ if status_code:
413
+ await self.record_api_call_result(filename, False, status_code, mode=mode)
414
+ else:
415
+ await self.record_api_call_result(filename, False, 400, mode=mode)
416
+
417
+ # 禁用失效凭证
418
+ try:
419
+ # 直接禁用该凭证(随机选择机制会自动跳过它)
420
+ disabled_ok = await self.update_credential_state(filename, {"disabled": True}, mode=mode)
421
+ if disabled_ok:
422
+ log.warning(f"永久失效凭证已禁用: {filename}")
423
+ else:
424
+ log.warning("永久失效凭证禁用失败,将由上层逻辑继续处理")
425
+ except Exception as e2:
426
+ log.error(f"禁用永久失效凭证时出错 {filename}: {e2}")
427
+ else:
428
+ # 网络错误或其他临时性错误,不封禁凭证
429
+ log.warning(f"Token刷新失败但非永久性错误 (HTTP {status_code}),不封禁凭证: {filename}")
430
+
431
+ return None
432
+
433
+ def _is_permanent_refresh_failure(self, error_msg: str, status_code: Optional[int] = None) -> bool:
434
+ """
435
+ 判断是否是凭证永久失效的错误
436
+
437
+ Args:
438
+ error_msg: 错误信息
439
+ status_code: HTTP状态码(如果有)
440
+
441
+ Returns:
442
+ True表示凭证永久失效应封禁,False表示临时错误不应封禁
443
+ """
444
+ # 优先使用HTTP状态码判断
445
+ if status_code is not None:
446
+ # 400/401/403 明确表示凭证有问题,应该封禁
447
+ if status_code in [400, 401, 403]:
448
+ log.debug(f"检测到客户端错误状态码 {status_code},判定为永久失效")
449
+ return True
450
+ # 500/502/503/504 是服务器错误,不应封禁凭证
451
+ elif status_code in [500, 502, 503, 504]:
452
+ log.debug(f"检测到服务器错误状态码 {status_code},不应封禁凭证")
453
+ return False
454
+ # 429 (限流) 不应封禁凭证
455
+ elif status_code == 429:
456
+ log.debug("检测到限流错误 429,不应封禁凭证")
457
+ return False
458
+
459
+ # 如果没有状态码,回退到错误信息匹配(谨慎判断)
460
+ # 只有明确的凭证失效错误才判定为永久失效
461
+ permanent_error_patterns = [
462
+ "invalid_grant",
463
+ "refresh_token_expired",
464
+ "invalid_refresh_token",
465
+ "unauthorized_client",
466
+ "access_denied",
467
+ ]
468
+
469
+ error_msg_lower = error_msg.lower()
470
+ for pattern in permanent_error_patterns:
471
+ if pattern.lower() in error_msg_lower:
472
+ log.debug(f"错误信息匹配到永久失效模式: {pattern}")
473
+ return True
474
+
475
+ # 默认认为是临时错误(如网络问题),不应封禁凭证
476
+ log.debug("未匹配到明确的永久失效模式,判定为临时错误")
477
+ return False
478
+
479
+ class _CredentialManagerSingleton:
480
+ """单例包装器,支持懒加载和自动初始化"""
481
+
482
+ _instance: Optional[CredentialManager] = None
483
+ _lock = None
484
+
485
+ def __init__(self):
486
+ self._manager = None
487
+
488
+ async def _get_or_create(self) -> CredentialManager:
489
+ """获取或创建单例实例(线程安全)"""
490
+ if self._instance is None:
491
+ # 简单的实例创建(异步环境下一般不需要复杂的锁)
492
+ if self._instance is None:
493
+ self._instance = CredentialManager()
494
+ await self._instance.initialize()
495
+ log.debug("CredentialManager singleton initialized")
496
+
497
+ return self._instance
498
+
499
+ def __getattr__(self, name):
500
+ """代理所有方法调用到真实的 CredentialManager 实例"""
501
+ async def _async_wrapper(*args, **kwargs):
502
+ manager = await self._get_or_create()
503
+ method = getattr(manager, name)
504
+ return await method(*args, **kwargs)
505
+
506
+ return _async_wrapper
507
+
508
+
509
+ # 全局单例实例 - 直接导入即可使用
510
+ credential_manager = _CredentialManagerSingleton()
src/google_oauth_api.py ADDED
@@ -0,0 +1,852 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Google OAuth2 认证模块
3
+ """
4
+
5
+ import time
6
+ import asyncio
7
+ from datetime import datetime, timedelta, timezone
8
+ from typing import Any, Dict, List, Optional, Tuple
9
+ from urllib.parse import urlencode
10
+
11
+ import jwt
12
+
13
+ from config import (
14
+ get_googleapis_proxy_url,
15
+ get_oauth_proxy_url,
16
+ get_resource_manager_api_url,
17
+ get_service_usage_api_url,
18
+ )
19
+ from log import log
20
+
21
+ from src.httpx_client import get_async, post_async
22
+
23
+
24
+ class TokenError(Exception):
25
+ """Token相关错误"""
26
+
27
+ pass
28
+
29
+
30
+ class Credentials:
31
+ """凭证类"""
32
+
33
+ def __init__(
34
+ self,
35
+ access_token: str,
36
+ refresh_token: str = None,
37
+ client_id: str = None,
38
+ client_secret: str = None,
39
+ expires_at: datetime = None,
40
+ project_id: str = None,
41
+ ):
42
+ self.access_token = access_token
43
+ self.refresh_token = refresh_token
44
+ self.client_id = client_id
45
+ self.client_secret = client_secret
46
+ self.expires_at = expires_at
47
+ self.project_id = project_id
48
+
49
+ # 反代配置将在使用时异步获取
50
+ self.oauth_base_url = None
51
+ self.token_endpoint = None
52
+
53
+ def is_expired(self) -> bool:
54
+ """检查token是否过期"""
55
+ if not self.expires_at:
56
+ return True
57
+
58
+ # 提前3分钟认为过期
59
+ buffer = timedelta(minutes=3)
60
+ return (self.expires_at - buffer) <= datetime.now(timezone.utc)
61
+
62
+ async def refresh_if_needed(self) -> bool:
63
+ """如果需要则刷新token"""
64
+ if not self.is_expired():
65
+ return False
66
+
67
+ if not self.refresh_token:
68
+ raise TokenError("需要刷新令牌但未提供")
69
+
70
+ await self.refresh()
71
+ return True
72
+
73
+ async def refresh(self):
74
+ """刷新访问令牌"""
75
+ if not self.refresh_token:
76
+ raise TokenError("无刷新令牌")
77
+
78
+ data = {
79
+ "client_id": self.client_id,
80
+ "client_secret": self.client_secret,
81
+ "refresh_token": self.refresh_token,
82
+ "grant_type": "refresh_token",
83
+ }
84
+
85
+ try:
86
+ oauth_base_url = await get_oauth_proxy_url()
87
+ token_url = f"{oauth_base_url.rstrip('/')}/token"
88
+ response = await post_async(
89
+ token_url,
90
+ data=data,
91
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
92
+ )
93
+ response.raise_for_status()
94
+
95
+ token_data = response.json()
96
+ self.access_token = token_data["access_token"]
97
+
98
+ if "expires_in" in token_data:
99
+ expires_in = int(token_data["expires_in"])
100
+ current_utc = datetime.now(timezone.utc)
101
+ self.expires_at = current_utc + timedelta(seconds=expires_in)
102
+ log.debug(
103
+ f"Token刷新: 当前UTC时间={current_utc.isoformat()}, "
104
+ f"有效期={expires_in}秒, "
105
+ f"过期时间={self.expires_at.isoformat()}"
106
+ )
107
+
108
+ if "refresh_token" in token_data:
109
+ self.refresh_token = token_data["refresh_token"]
110
+
111
+ log.debug(f"Token刷新成功,过期时间: {self.expires_at}")
112
+
113
+ except Exception as e:
114
+ error_msg = str(e)
115
+ status_code = None
116
+ if hasattr(e, 'response') and hasattr(e.response, 'status_code'):
117
+ status_code = e.response.status_code
118
+ error_msg = f"Token刷新失败 (HTTP {status_code}): {error_msg}"
119
+ else:
120
+ error_msg = f"Token刷新失败: {error_msg}"
121
+
122
+ log.error(error_msg)
123
+ token_error = TokenError(error_msg)
124
+ token_error.status_code = status_code
125
+ raise token_error
126
+
127
+ @classmethod
128
+ def from_dict(cls, data: Dict[str, Any]) -> "Credentials":
129
+ """从字典创建凭证"""
130
+ # 处理过期时间
131
+ expires_at = None
132
+ if "expiry" in data and data["expiry"]:
133
+ try:
134
+ expiry_str = data["expiry"]
135
+ if isinstance(expiry_str, str):
136
+ if expiry_str.endswith("Z"):
137
+ expires_at = datetime.fromisoformat(expiry_str.replace("Z", "+00:00"))
138
+ elif "+" in expiry_str:
139
+ expires_at = datetime.fromisoformat(expiry_str)
140
+ else:
141
+ expires_at = datetime.fromisoformat(expiry_str).replace(tzinfo=timezone.utc)
142
+ except ValueError:
143
+ log.warning(f"无法解析过期时间: {expiry_str}")
144
+
145
+ return cls(
146
+ access_token=data.get("token") or data.get("access_token", ""),
147
+ refresh_token=data.get("refresh_token"),
148
+ client_id=data.get("client_id"),
149
+ client_secret=data.get("client_secret"),
150
+ expires_at=expires_at,
151
+ project_id=data.get("project_id"),
152
+ )
153
+
154
+ def to_dict(self) -> Dict[str, Any]:
155
+ """转为字典"""
156
+ result = {
157
+ "access_token": self.access_token,
158
+ "refresh_token": self.refresh_token,
159
+ "client_id": self.client_id,
160
+ "client_secret": self.client_secret,
161
+ "project_id": self.project_id,
162
+ }
163
+
164
+ if self.expires_at:
165
+ result["expiry"] = self.expires_at.isoformat()
166
+
167
+ return result
168
+
169
+
170
+ class Flow:
171
+ """OAuth流程类"""
172
+
173
+ def __init__(
174
+ self, client_id: str, client_secret: str, scopes: List[str], redirect_uri: str = None
175
+ ):
176
+ self.client_id = client_id
177
+ self.client_secret = client_secret
178
+ self.scopes = scopes
179
+ self.redirect_uri = redirect_uri
180
+
181
+ # 反代配置将在使用时异步获取
182
+ self.oauth_base_url = None
183
+ self.token_endpoint = None
184
+ self.auth_endpoint = "https://accounts.google.com/o/oauth2/auth"
185
+
186
+ self.credentials: Optional[Credentials] = None
187
+
188
+ def get_auth_url(self, state: str = None, **kwargs) -> str:
189
+ """生成授权URL"""
190
+ params = {
191
+ "client_id": self.client_id,
192
+ "redirect_uri": self.redirect_uri,
193
+ "scope": " ".join(self.scopes),
194
+ "response_type": "code",
195
+ "access_type": "offline",
196
+ "prompt": "consent",
197
+ "include_granted_scopes": "true",
198
+ }
199
+
200
+ if state:
201
+ params["state"] = state
202
+
203
+ params.update(kwargs)
204
+ return f"{self.auth_endpoint}?{urlencode(params)}"
205
+
206
+ async def exchange_code(self, code: str) -> Credentials:
207
+ """用授权码换取token"""
208
+ data = {
209
+ "client_id": self.client_id,
210
+ "client_secret": self.client_secret,
211
+ "redirect_uri": self.redirect_uri,
212
+ "code": code,
213
+ "grant_type": "authorization_code",
214
+ }
215
+
216
+ try:
217
+ oauth_base_url = await get_oauth_proxy_url()
218
+ token_url = f"{oauth_base_url.rstrip('/')}/token"
219
+ response = await post_async(
220
+ token_url, data=data, headers={"Content-Type": "application/x-www-form-urlencoded"}
221
+ )
222
+ response.raise_for_status()
223
+
224
+ token_data = response.json()
225
+
226
+ # 计算过期时间
227
+ expires_at = None
228
+ if "expires_in" in token_data:
229
+ expires_in = int(token_data["expires_in"])
230
+ expires_at = datetime.now(timezone.utc) + timedelta(seconds=expires_in)
231
+
232
+ # 创建凭证对象
233
+ self.credentials = Credentials(
234
+ access_token=token_data["access_token"],
235
+ refresh_token=token_data.get("refresh_token"),
236
+ client_id=self.client_id,
237
+ client_secret=self.client_secret,
238
+ expires_at=expires_at,
239
+ )
240
+
241
+ return self.credentials
242
+
243
+ except Exception as e:
244
+ error_msg = f"获取token失败: {str(e)}"
245
+ log.error(error_msg)
246
+ raise TokenError(error_msg)
247
+
248
+
249
+ class ServiceAccount:
250
+ """Service Account类"""
251
+
252
+ def __init__(
253
+ self, email: str, private_key: str, project_id: str = None, scopes: List[str] = None
254
+ ):
255
+ self.email = email
256
+ self.private_key = private_key
257
+ self.project_id = project_id
258
+ self.scopes = scopes or []
259
+
260
+ # 反代配置将在使用时异步获取
261
+ self.oauth_base_url = None
262
+ self.token_endpoint = None
263
+
264
+ self.access_token: Optional[str] = None
265
+ self.expires_at: Optional[datetime] = None
266
+
267
+ def is_expired(self) -> bool:
268
+ """检查token是否过期"""
269
+ if not self.expires_at:
270
+ return True
271
+
272
+ buffer = timedelta(minutes=3)
273
+ return (self.expires_at - buffer) <= datetime.now(timezone.utc)
274
+
275
+ def create_jwt(self) -> str:
276
+ """创建JWT令牌"""
277
+ now = int(time.time())
278
+
279
+ payload = {
280
+ "iss": self.email,
281
+ "scope": " ".join(self.scopes) if self.scopes else "",
282
+ "aud": self.token_endpoint,
283
+ "exp": now + 3600,
284
+ "iat": now,
285
+ }
286
+
287
+ return jwt.encode(payload, self.private_key, algorithm="RS256")
288
+
289
+ async def get_access_token(self) -> str:
290
+ """获取访问令牌"""
291
+ if not self.is_expired() and self.access_token:
292
+ return self.access_token
293
+
294
+ assertion = self.create_jwt()
295
+
296
+ data = {"grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer", "assertion": assertion}
297
+
298
+ try:
299
+ oauth_base_url = await get_oauth_proxy_url()
300
+ token_url = f"{oauth_base_url.rstrip('/')}/token"
301
+ response = await post_async(
302
+ token_url, data=data, headers={"Content-Type": "application/x-www-form-urlencoded"}
303
+ )
304
+ response.raise_for_status()
305
+
306
+ token_data = response.json()
307
+ self.access_token = token_data["access_token"]
308
+
309
+ if "expires_in" in token_data:
310
+ expires_in = int(token_data["expires_in"])
311
+ self.expires_at = datetime.now(timezone.utc) + timedelta(seconds=expires_in)
312
+
313
+ return self.access_token
314
+
315
+ except Exception as e:
316
+ error_msg = f"Service Account获取token失败: {str(e)}"
317
+ log.error(error_msg)
318
+ raise TokenError(error_msg)
319
+
320
+ @classmethod
321
+ def from_dict(cls, data: Dict[str, Any], scopes: List[str] = None) -> "ServiceAccount":
322
+ """从字典创建Service Account凭证"""
323
+ return cls(
324
+ email=data["client_email"],
325
+ private_key=data["private_key"],
326
+ project_id=data.get("project_id"),
327
+ scopes=scopes,
328
+ )
329
+
330
+
331
+ # 工具函数
332
+ async def get_user_info(credentials: Credentials) -> Optional[Dict[str, Any]]:
333
+ """获取用户信息"""
334
+ await credentials.refresh_if_needed()
335
+
336
+ try:
337
+ googleapis_base_url = await get_googleapis_proxy_url()
338
+ userinfo_url = f"{googleapis_base_url.rstrip('/')}/oauth2/v2/userinfo"
339
+ response = await get_async(
340
+ userinfo_url, headers={"Authorization": f"Bearer {credentials.access_token}"}
341
+ )
342
+ response.raise_for_status()
343
+ return response.json()
344
+ except Exception as e:
345
+ log.error(f"获取用户信息失败: {e}")
346
+ return None
347
+
348
+
349
+ async def get_user_email(credentials: Credentials) -> Optional[str]:
350
+ """获取用户邮箱地址"""
351
+ try:
352
+ # 确保凭证有效
353
+ await credentials.refresh_if_needed()
354
+
355
+ # 调用Google userinfo API获取邮箱
356
+ user_info = await get_user_info(credentials)
357
+ if user_info:
358
+ email = user_info.get("email")
359
+ if email:
360
+ log.info(f"成功获取邮箱地址: {email}")
361
+ return email
362
+ else:
363
+ log.warning(f"userinfo响应中没有邮箱信息: {user_info}")
364
+ return None
365
+ else:
366
+ log.warning("获取用户信息失败")
367
+ return None
368
+
369
+ except Exception as e:
370
+ log.error(f"获取用户邮箱失败: {e}")
371
+ return None
372
+
373
+
374
+ async def fetch_user_email_from_file(cred_data: Dict[str, Any]) -> Optional[str]:
375
+ """从凭证数据获取用户邮箱地址(支持统一存储)"""
376
+ try:
377
+ # 直接从凭证数据创建凭证对象
378
+ credentials = Credentials.from_dict(cred_data)
379
+ if not credentials or not credentials.access_token:
380
+ log.warning("无法从凭证数据创建凭证对象或获取访问令牌")
381
+ return None
382
+
383
+ # 获取邮箱
384
+ return await get_user_email(credentials)
385
+
386
+ except Exception as e:
387
+ log.error(f"从凭证数据获取用户邮箱失败: {e}")
388
+ return None
389
+
390
+
391
+ async def validate_token(token: str) -> Optional[Dict[str, Any]]:
392
+ """验证访问令牌"""
393
+ try:
394
+ oauth_base_url = await get_oauth_proxy_url()
395
+ tokeninfo_url = f"{oauth_base_url.rstrip('/')}/tokeninfo?access_token={token}"
396
+
397
+ response = await get_async(tokeninfo_url)
398
+ response.raise_for_status()
399
+ return response.json()
400
+ except Exception as e:
401
+ log.error(f"验证令牌失败: {e}")
402
+ return None
403
+
404
+
405
+ async def enable_required_apis(credentials: Credentials, project_id: str) -> bool:
406
+ """自动启用必需的API服务"""
407
+ try:
408
+ # 确保凭证有效
409
+ if credentials.is_expired() and credentials.refresh_token:
410
+ await credentials.refresh()
411
+
412
+ headers = {
413
+ "Authorization": f"Bearer {credentials.access_token}",
414
+ "Content-Type": "application/json",
415
+ "User-Agent": "geminicli-oauth/1.0",
416
+ }
417
+
418
+ # 需要启用的服务列表
419
+ required_services = [
420
+ "geminicloudassist.googleapis.com", # Gemini Cloud Assist API
421
+ "cloudaicompanion.googleapis.com", # Gemini for Google Cloud API
422
+ ]
423
+
424
+ for service in required_services:
425
+ log.info(f"正在检查并启用服务: {service}")
426
+
427
+ # 检查服务是否已启用
428
+ service_usage_base_url = await get_service_usage_api_url()
429
+ check_url = (
430
+ f"{service_usage_base_url.rstrip('/')}/v1/projects/{project_id}/services/{service}"
431
+ )
432
+ try:
433
+ check_response = await get_async(check_url, headers=headers)
434
+ if check_response.status_code == 200:
435
+ service_data = check_response.json()
436
+ if service_data.get("state") == "ENABLED":
437
+ log.info(f"服务 {service} 已启用")
438
+ continue
439
+ except Exception as e:
440
+ log.debug(f"检查服务状态失败,将尝试启用: {e}")
441
+
442
+ # 启用服务
443
+ enable_url = f"{service_usage_base_url.rstrip('/')}/v1/projects/{project_id}/services/{service}:enable"
444
+ try:
445
+ enable_response = await post_async(enable_url, headers=headers, json={})
446
+
447
+ if enable_response.status_code in [200, 201]:
448
+ log.info(f"✅ 成功启用服务: {service}")
449
+ elif enable_response.status_code == 400:
450
+ error_data = enable_response.json()
451
+ if "already enabled" in error_data.get("error", {}).get("message", "").lower():
452
+ log.info(f"✅ 服务 {service} 已经启用")
453
+ else:
454
+ log.warning(f"⚠️ 启用服务 {service} 时出现警告: {error_data}")
455
+ else:
456
+ log.warning(
457
+ f"⚠️ 启用服务 {service} 失败: {enable_response.status_code} - {enable_response.text}"
458
+ )
459
+
460
+ except Exception as e:
461
+ log.warning(f"⚠️ 启用服务 {service} 时发生异常: {e}")
462
+
463
+ return True
464
+
465
+ except Exception as e:
466
+ log.error(f"启用API服务时发生错误: {e}")
467
+ return False
468
+
469
+
470
+ async def get_user_projects(credentials: Credentials) -> List[Dict[str, Any]]:
471
+ """获取用户可访问的Google Cloud项目列表"""
472
+ try:
473
+ # 确保凭证有效
474
+ if credentials.is_expired() and credentials.refresh_token:
475
+ await credentials.refresh()
476
+
477
+ headers = {
478
+ "Authorization": f"Bearer {credentials.access_token}",
479
+ "User-Agent": "geminicli-oauth/1.0",
480
+ }
481
+
482
+ # 使用Resource Manager API的正确域名和端点
483
+ resource_manager_base_url = await get_resource_manager_api_url()
484
+ url = f"{resource_manager_base_url.rstrip('/')}/v1/projects"
485
+ log.info(f"正在调用API: {url}")
486
+ response = await get_async(url, headers=headers)
487
+
488
+ log.info(f"API响应状态码: {response.status_code}")
489
+ if response.status_code != 200:
490
+ log.error(f"API响应内容: {response.text}")
491
+
492
+ if response.status_code == 200:
493
+ data = response.json()
494
+ projects = data.get("projects", [])
495
+ # 只返回活跃的项目
496
+ active_projects = [
497
+ project for project in projects if project.get("lifecycleState") == "ACTIVE"
498
+ ]
499
+ log.info(f"获取到 {len(active_projects)} 个活跃项目")
500
+ return active_projects
501
+ else:
502
+ log.warning(f"获取项目列表失败: {response.status_code} - {response.text}")
503
+ return []
504
+
505
+ except Exception as e:
506
+ log.error(f"获取用户项目列表失败: {e}")
507
+ return []
508
+
509
+
510
+ async def select_default_project(projects: List[Dict[str, Any]]) -> Optional[str]:
511
+ """从项目列表中选择默认项目"""
512
+ if not projects:
513
+ return None
514
+
515
+ # 策略1:查找显示名称或项目ID包含"default"的项目
516
+ for project in projects:
517
+ display_name = project.get("displayName", "").lower()
518
+ # Google API returns projectId in camelCase
519
+ project_id = project.get("projectId", "")
520
+ if "default" in display_name or "default" in project_id.lower():
521
+ log.info(f"选择默认项目: {project_id} ({project.get('displayName', project_id)})")
522
+ return project_id
523
+
524
+ # 策略2:选择第一个项目
525
+ first_project = projects[0]
526
+ # Google API returns projectId in camelCase
527
+ project_id = first_project.get("projectId", "")
528
+ log.info(
529
+ f"选择第一个项目作为默认: {project_id} ({first_project.get('displayName', project_id)})"
530
+ )
531
+ return project_id
532
+
533
+
534
+ async def fetch_project_id_and_tier(
535
+ access_token: str,
536
+ user_agent: str,
537
+ api_base_url: str,
538
+ include_credits: bool = False,
539
+ ) -> Tuple[Optional[str], Optional[str]] | Tuple[Optional[str], Optional[str], Optional[int]]:
540
+ """
541
+ 从 API 获取 project_id 和订阅等级
542
+
543
+ Args:
544
+ access_token: Google OAuth access token
545
+ user_agent: User-Agent header
546
+ api_base_url: API base URL (e.g., antigravity or code assist endpoint)
547
+
548
+ Returns:
549
+ 默认返回 (project_id, subscription_tier)
550
+ 当 include_credits=True 时返回 (project_id, subscription_tier, credit_amount)
551
+ subscription_tier 可能是 "FREE", "PRO", "ULTRA" 或 None
552
+ credit_amount 为积分数量(整数)或 None
553
+ """
554
+ headers = {
555
+ 'User-Agent': user_agent,
556
+ 'Authorization': f'Bearer {access_token}',
557
+ 'Content-Type': 'application/json',
558
+ 'Accept-Encoding': 'gzip'
559
+ }
560
+
561
+ def _map_raw_tier(raw_tier: Optional[str]) -> Optional[str]:
562
+ """将 loadCodeAssist 返回的 raw tier 映射为统一 tier。"""
563
+ if not raw_tier:
564
+ return None
565
+
566
+ tier_mapping = {
567
+ "g1-ultra-tier": "ultra",
568
+ "ws-ai-ultra-business-tier": "ultra",
569
+ "g1-pro-tier": "pro",
570
+ "helium-tier": "pro",
571
+ "standard-tier": "pro",
572
+ "free-tier": "free",
573
+ }
574
+
575
+ return tier_mapping.get(raw_tier.lower(), "pro")
576
+
577
+ subscription_tier = None
578
+ credit_amount: Optional[int] = None
579
+
580
+ # 步骤 1: 尝试 loadCodeAssist
581
+ try:
582
+ project_id, raw_tier, raw_credit_amount = await _try_load_code_assist(api_base_url, headers)
583
+ subscription_tier = _map_raw_tier(raw_tier)
584
+
585
+ if raw_credit_amount is not None:
586
+ try:
587
+ credit_amount = int(raw_credit_amount)
588
+ log.info(
589
+ f"[fetch_project_id_and_tier] Found credit_amount: {credit_amount}"
590
+ )
591
+ except (TypeError, ValueError):
592
+ log.warning(
593
+ f"[fetch_project_id_and_tier] Invalid credit_amount: {raw_credit_amount}"
594
+ )
595
+
596
+ if raw_tier:
597
+ log.info(
598
+ f"[fetch_project_id_and_tier] Raw tier '{raw_tier}' mapped to '{subscription_tier}'"
599
+ )
600
+
601
+ if project_id:
602
+ if include_credits:
603
+ return project_id, subscription_tier, credit_amount
604
+ return project_id, subscription_tier
605
+
606
+ log.warning("[fetch_project_id_and_tier] loadCodeAssist did not return project_id, falling back to onboardUser")
607
+
608
+ except Exception as e:
609
+ log.warning(f"[fetch_project_id_and_tier] loadCodeAssist failed: {type(e).__name__}: {e}")
610
+ log.warning("[fetch_project_id_and_tier] Falling back to onboardUser")
611
+
612
+ # 步骤 2: 回退到 onboardUser
613
+ try:
614
+ project_id = await _try_onboard_user(api_base_url, headers)
615
+ if project_id:
616
+ if include_credits:
617
+ return project_id, subscription_tier, credit_amount
618
+ return project_id, subscription_tier
619
+
620
+ log.error("[fetch_project_id_and_tier] Failed to get project_id from both loadCodeAssist and onboardUser")
621
+ if include_credits:
622
+ return None, subscription_tier, credit_amount
623
+ return None, subscription_tier
624
+
625
+ except Exception as e:
626
+ log.error(f"[fetch_project_id_and_tier] onboardUser failed: {type(e).__name__}: {e}")
627
+ import traceback
628
+ log.debug(f"[fetch_project_id_and_tier] Traceback: {traceback.format_exc()}")
629
+ if include_credits:
630
+ return None, subscription_tier, credit_amount
631
+ return None, subscription_tier
632
+
633
+
634
+ async def _try_load_code_assist(
635
+ api_base_url: str,
636
+ headers: dict
637
+ ) -> Tuple[Optional[str], Optional[str], Optional[str]]:
638
+ """
639
+ 尝试通过 loadCodeAssist 获取 project_id 和订阅等级
640
+
641
+ Returns:
642
+ (project_id, subscription_tier, credit_amount) 元组
643
+ subscription_tier 可能是 "FREE", "PRO", "ULTRA" 或 None
644
+ credit_amount 为字符串格式积分或 None
645
+ """
646
+ request_url = f"{api_base_url.rstrip('/')}/v1internal:loadCodeAssist"
647
+ request_body = {
648
+ "metadata": {
649
+ "ideType": "ANTIGRAVITY"
650
+ }
651
+ }
652
+
653
+ log.debug(f"[loadCodeAssist] Fetching project_id from: {request_url}")
654
+ log.debug(f"[loadCodeAssist] Request body: {request_body}")
655
+
656
+ response = await post_async(
657
+ request_url,
658
+ json=request_body,
659
+ headers=headers,
660
+ timeout=30.0,
661
+ )
662
+
663
+ log.debug(f"[loadCodeAssist] Response status: {response.status_code}")
664
+
665
+ if response.status_code == 200:
666
+ response_text = response.text
667
+ log.debug(f"[loadCodeAssist] Response body: {response_text}")
668
+
669
+ data = response.json()
670
+ log.debug(f"[loadCodeAssist] Response JSON keys: {list(data.keys())}")
671
+
672
+ # 提取订阅等级 - 优先使用 paidTier(更准确反映实际权益)
673
+ paid_tier = data.get("paidTier", {})
674
+ current_tier = data.get("currentTier", {})
675
+ available_credits = paid_tier.get("availableCredits", []) if isinstance(paid_tier, dict) else []
676
+
677
+ # paidTier.id 优先,然后是 currentTier.id
678
+ subscription_tier = None
679
+ if isinstance(paid_tier, dict) and paid_tier.get("id"):
680
+ subscription_tier = paid_tier.get("id")
681
+ log.info(f"[loadCodeAssist] Found paidTier: {subscription_tier}")
682
+ elif isinstance(current_tier, dict) and current_tier.get("id"):
683
+ subscription_tier = current_tier.get("id")
684
+ log.info(f"[loadCodeAssist] Found currentTier: {subscription_tier}")
685
+
686
+ # 提取积分数量(如果返回了 availableCredits)
687
+ credit_amount = None
688
+ if isinstance(available_credits, list) and available_credits:
689
+ first_credit = available_credits[0]
690
+ if isinstance(first_credit, dict):
691
+ credit_amount = first_credit.get("creditAmount")
692
+ if credit_amount is not None:
693
+ log.info(f"[loadCodeAssist] Found creditAmount: {credit_amount}")
694
+
695
+ # 检查是否有 currentTier(表示用户已激活)
696
+ if current_tier:
697
+ log.info("[loadCodeAssist] User is already activated")
698
+
699
+ # 使用服务器返回的 project_id
700
+ project_id = data.get("cloudaicompanionProject")
701
+ if project_id:
702
+ log.info(f"[loadCodeAssist] Successfully fetched project_id: {project_id}, tier: {subscription_tier}")
703
+ return project_id, subscription_tier, credit_amount
704
+
705
+ log.warning("[loadCodeAssist] No project_id in response")
706
+ return None, subscription_tier, credit_amount
707
+ else:
708
+ log.info("[loadCodeAssist] User not activated yet (no currentTier)")
709
+ return None, None, credit_amount
710
+ else:
711
+ log.warning(f"[loadCodeAssist] Failed: HTTP {response.status_code}")
712
+ log.warning(f"[loadCodeAssist] Response body: {response.text[:500]}")
713
+ raise Exception(f"HTTP {response.status_code}: {response.text[:200]}")
714
+
715
+
716
+ async def _try_onboard_user(
717
+ api_base_url: str,
718
+ headers: dict
719
+ ) -> Optional[str]:
720
+ """
721
+ 尝试通过 onboardUser 获取 project_id(长时间运行操作,需要轮询)
722
+
723
+ Returns:
724
+ project_id 或 None
725
+ """
726
+ request_url = f"{api_base_url.rstrip('/')}/v1internal:onboardUser"
727
+
728
+ # 首先需要获取用户的 tier 信息
729
+ tier_id = await _get_onboard_tier(api_base_url, headers)
730
+ if not tier_id:
731
+ log.error("[onboardUser] Failed to determine user tier")
732
+ return None
733
+
734
+ log.info(f"[onboardUser] User tier: {tier_id}")
735
+
736
+ # 构造 onboardUser 请求
737
+ # 注意:FREE tier 不应该包含 cloudaicompanionProject
738
+ request_body = {
739
+ "tierId": tier_id,
740
+ "metadata": {
741
+ "ideType": "ANTIGRAVITY",
742
+ "platform": "PLATFORM_UNSPECIFIED",
743
+ "pluginType": "GEMINI"
744
+ }
745
+ }
746
+
747
+ log.debug(f"[onboardUser] Request URL: {request_url}")
748
+ log.debug(f"[onboardUser] Request body: {request_body}")
749
+
750
+ # onboardUser 是长时间运行操作,需要轮询
751
+ # 最多等待 10 秒(5 次 * 2 秒)
752
+ max_attempts = 5
753
+ attempt = 0
754
+
755
+ while attempt < max_attempts:
756
+ attempt += 1
757
+ log.debug(f"[onboardUser] Polling attempt {attempt}/{max_attempts}")
758
+
759
+ response = await post_async(
760
+ request_url,
761
+ json=request_body,
762
+ headers=headers,
763
+ timeout=30.0,
764
+ )
765
+
766
+ log.debug(f"[onboardUser] Response status: {response.status_code}")
767
+
768
+ if response.status_code == 200:
769
+ data = response.json()
770
+ log.debug(f"[onboardUser] Response data: {data}")
771
+
772
+ # 检查长时间运行操作是否完成
773
+ if data.get("done"):
774
+ log.info("[onboardUser] Operation completed")
775
+
776
+ # 从响应中提取 project_id
777
+ response_data = data.get("response", {})
778
+ project_obj = response_data.get("cloudaicompanionProject", {})
779
+
780
+ if isinstance(project_obj, dict):
781
+ project_id = project_obj.get("id")
782
+ elif isinstance(project_obj, str):
783
+ project_id = project_obj
784
+ else:
785
+ project_id = None
786
+
787
+ if project_id:
788
+ log.info(f"[onboardUser] Successfully fetched project_id: {project_id}")
789
+ return project_id
790
+ else:
791
+ log.warning("[onboardUser] Operation completed but no project_id in response")
792
+ return None
793
+ else:
794
+ log.debug("[onboardUser] Operation still in progress, waiting 2 seconds...")
795
+ await asyncio.sleep(2)
796
+ else:
797
+ log.warning(f"[onboardUser] Failed: HTTP {response.status_code}")
798
+ log.warning(f"[onboardUser] Response body: {response.text[:500]}")
799
+ raise Exception(f"HTTP {response.status_code}: {response.text[:200]}")
800
+
801
+ log.error("[onboardUser] Timeout: Operation did not complete within 10 seconds")
802
+ return None
803
+
804
+
805
+ async def _get_onboard_tier(
806
+ api_base_url: str,
807
+ headers: dict
808
+ ) -> Optional[str]:
809
+ """
810
+ 从 loadCodeAssist 响应中获取用户应该注册的 tier
811
+
812
+ Returns:
813
+ tier_id (如 "FREE", "STANDARD", "LEGACY") 或 None
814
+ """
815
+ request_url = f"{api_base_url.rstrip('/')}/v1internal:loadCodeAssist"
816
+ request_body = {
817
+ "metadata": {
818
+ "ideType": "ANTIGRAVITY",
819
+ "platform": "PLATFORM_UNSPECIFIED",
820
+ "pluginType": "GEMINI"
821
+ }
822
+ }
823
+
824
+ log.debug(f"[_get_onboard_tier] Fetching tier info from: {request_url}")
825
+
826
+ response = await post_async(
827
+ request_url,
828
+ json=request_body,
829
+ headers=headers,
830
+ timeout=30.0,
831
+ )
832
+
833
+ if response.status_code == 200:
834
+ data = response.json()
835
+ log.debug(f"[_get_onboard_tier] Response data: {data}")
836
+
837
+ # 查找默认的 tier
838
+ allowed_tiers = data.get("allowedTiers", [])
839
+ for tier in allowed_tiers:
840
+ if tier.get("isDefault"):
841
+ tier_id = tier.get("id")
842
+ log.info(f"[_get_onboard_tier] Found default tier: {tier_id}")
843
+ return tier_id
844
+
845
+ # 如果没有默认 tier,使用 LEGACY 作为回退
846
+ log.warning("[_get_onboard_tier] No default tier found, using LEGACY")
847
+ return "LEGACY"
848
+ else:
849
+ log.error(f"[_get_onboard_tier] Failed to fetch tier info: HTTP {response.status_code}")
850
+ return None
851
+
852
+
src/httpx_client.py ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 通用的HTTP客户端模块
3
+ 为所有需要使用httpx的模块提供统一的客户端配置和方法
4
+ 保持通用性,不与特定业务逻辑耦合
5
+ """
6
+
7
+ from contextlib import asynccontextmanager
8
+ from typing import Any, AsyncGenerator, Dict, Optional
9
+
10
+ import httpx
11
+
12
+ from config import get_proxy_config
13
+ from log import log
14
+
15
+
16
+ class HttpxClientManager:
17
+ """通用HTTP客户端管理器"""
18
+
19
+ async def get_client_kwargs(self, timeout: float = 30.0, **kwargs) -> Dict[str, Any]:
20
+ """获取httpx客户端的通用配置参数"""
21
+ client_kwargs = {"timeout": timeout, **kwargs}
22
+
23
+ # 动态读取代理配置,支持热更新
24
+ current_proxy_config = await get_proxy_config()
25
+ if current_proxy_config:
26
+ client_kwargs["proxy"] = current_proxy_config
27
+
28
+ return client_kwargs
29
+
30
+ @asynccontextmanager
31
+ async def get_client(
32
+ self, timeout: float = 30.0, **kwargs
33
+ ) -> AsyncGenerator[httpx.AsyncClient, None]:
34
+ """获取配置好的异步HTTP客户端"""
35
+ client_kwargs = await self.get_client_kwargs(timeout=timeout, **kwargs)
36
+
37
+ async with httpx.AsyncClient(**client_kwargs) as client:
38
+ yield client
39
+
40
+ @asynccontextmanager
41
+ async def get_streaming_client(
42
+ self, timeout: float = None, **kwargs
43
+ ) -> AsyncGenerator[httpx.AsyncClient, None]:
44
+ """获取用于流式请求的HTTP客户端(无超时限制)"""
45
+ client_kwargs = await self.get_client_kwargs(timeout=timeout, **kwargs)
46
+
47
+ # 创建独立的客户端实例用于流式处理
48
+ client = httpx.AsyncClient(**client_kwargs)
49
+ try:
50
+ yield client
51
+ finally:
52
+ # 确保无论发生什么都关闭客户端
53
+ try:
54
+ await client.aclose()
55
+ except Exception as e:
56
+ log.warning(f"Error closing streaming client: {e}")
57
+
58
+
59
+ # 全局HTTP客户端管理器实例
60
+ http_client = HttpxClientManager()
61
+
62
+
63
+ # 通用的异步方法
64
+ async def get_async(
65
+ url: str, headers: Optional[Dict[str, str]] = None, timeout: float = 30.0, **kwargs
66
+ ) -> httpx.Response:
67
+ """通用异步GET请求"""
68
+ async with http_client.get_client(timeout=timeout, **kwargs) as client:
69
+ return await client.get(url, headers=headers)
70
+
71
+
72
+ async def post_async(
73
+ url: str,
74
+ data: Any = None,
75
+ json: Any = None,
76
+ headers: Optional[Dict[str, str]] = None,
77
+ timeout: float = 900.0,
78
+ **kwargs,
79
+ ) -> httpx.Response:
80
+ """通用异步POST请求"""
81
+ async with http_client.get_client(timeout=timeout, **kwargs) as client:
82
+ return await client.post(url, data=data, json=json, headers=headers)
83
+
84
+
85
+ # 调试用:设为 True 时所有流式请求都返回 429
86
+ _MOCK_STREAM_429 = False
87
+
88
+ async def stream_post_async(
89
+ url: str,
90
+ body: Dict[str, Any],
91
+ native: bool = False,
92
+ headers: Optional[Dict[str, str]] = None,
93
+ **kwargs,
94
+ ):
95
+ """流式异步POST请求"""
96
+ if _MOCK_STREAM_429:
97
+ from fastapi import Response
98
+ import json
99
+ log.warning(f"[MOCK] stream_post_async: 返回模拟429错误")
100
+ yield Response(
101
+ content=json.dumps({"error": {"code": 429, "message": "mock rate limit", "status": "RESOURCE_EXHAUSTED"}}),
102
+ status_code=429,
103
+ )
104
+ return
105
+
106
+ async with http_client.get_streaming_client(**kwargs) as client:
107
+ async with client.stream("POST", url, json=body, headers=headers) as r:
108
+ # 错误直接返回
109
+ if r.status_code != 200:
110
+ from fastapi import Response
111
+ yield Response(await r.aread(), r.status_code, dict(r.headers))
112
+ return
113
+
114
+ # 如果native=True,直接返回bytes流
115
+ if native:
116
+ async for chunk in r.aiter_bytes():
117
+ yield chunk
118
+ else:
119
+ # 通过aiter_lines转化成str流返回
120
+ async for line in r.aiter_lines():
121
+ yield line
src/keeplive.py ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 保活服务模块
3
+ 定期向配置的URL发送GET请求,保持服务在线
4
+ 未配置保活URL时不启动任何任务,零资源占用
5
+ """
6
+
7
+ import asyncio
8
+ from typing import Optional
9
+
10
+ from config import get_keepalive_interval, get_keepalive_url
11
+ from log import log
12
+ from src.httpx_client import get_async
13
+
14
+
15
+ class KeepAliveService:
16
+ """保活服务:定期向指定URL发送GET请求"""
17
+
18
+ def __init__(self):
19
+ self._task: Optional[asyncio.Task] = None
20
+
21
+ async def _run(self, url: str, interval: int):
22
+ """保活循环,读取到有效URL才会被调用"""
23
+ log.info(f"[KeepAlive] 保活任务启动,URL={url},间隔={interval}s")
24
+ while True:
25
+ try:
26
+ response = await get_async(url, timeout=30.0)
27
+ log.info(f"[KeepAlive] GET {url} -> {response.status_code}")
28
+ except asyncio.CancelledError:
29
+ raise
30
+ except Exception as e:
31
+ log.warning(f"[KeepAlive] GET {url} 失败: {e}")
32
+
33
+ try:
34
+ await asyncio.sleep(interval)
35
+ except asyncio.CancelledError:
36
+ raise
37
+
38
+ async def start(self):
39
+ """
40
+ 启动保活服务。
41
+ 仅当配置了有效的保活URL时才创建后台任务,否则零开销。
42
+ """
43
+ if self._task and not self._task.done():
44
+ # 已有任务在运行,不重复启动
45
+ return
46
+
47
+ url = await get_keepalive_url()
48
+ interval = await get_keepalive_interval()
49
+
50
+ if not url or not url.strip():
51
+ log.debug("[KeepAlive] 未配置保活URL,保活服务不启动")
52
+ return
53
+
54
+ if interval <= 0:
55
+ log.warning(f"[KeepAlive] 保活间隔无效({interval}),保活服务不启动")
56
+ return
57
+
58
+ self._task = asyncio.create_task(
59
+ self._run(url.strip(), interval), name="keepalive_service"
60
+ )
61
+
62
+ async def stop(self):
63
+ """停止保活服务"""
64
+ if self._task and not self._task.done():
65
+ self._task.cancel()
66
+ try:
67
+ await self._task
68
+ except asyncio.CancelledError:
69
+ pass
70
+ log.info("[KeepAlive] 保活服务已停止")
71
+ self._task = None
72
+
73
+ async def restart(self):
74
+ """
75
+ 重启保活服务。
76
+ 配置变更时调用,会停止旧任务并根据最新配置决定是否启动新任务。
77
+ """
78
+ await self.stop()
79
+ await self.start()
80
+
81
+ @property
82
+ def is_running(self) -> bool:
83
+ """当前保活任务是否在运行"""
84
+ return self._task is not None and not self._task.done()
85
+
86
+
87
+ # 全局保活服务实例
88
+ keepalive_service = KeepAliveService()
src/models.py ADDED
@@ -0,0 +1,383 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Any, Dict, List, Optional, Union
2
+
3
+ from pydantic import BaseModel, Field
4
+
5
+
6
+ # Pydantic v1/v2 兼容性辅助函数
7
+ def model_to_dict(model: BaseModel) -> Dict[str, Any]:
8
+ """
9
+ 兼容 Pydantic v1 和 v2 的模型转字典方法,排除 None 值
10
+ - v1: model.dict(exclude_none=True)
11
+ - v2: model.model_dump(exclude_none=True)
12
+ """
13
+ if hasattr(model, 'model_dump'):
14
+ # Pydantic v2
15
+ return model.model_dump(exclude_none=True)
16
+ else:
17
+ # Pydantic v1
18
+ return model.dict(exclude_none=True)
19
+
20
+
21
+ # Common Models
22
+ class Model(BaseModel):
23
+ id: str
24
+ object: str = "model"
25
+ created: Optional[int] = None
26
+ owned_by: Optional[str] = "google"
27
+
28
+
29
+ class ModelList(BaseModel):
30
+ object: str = "list"
31
+ data: List[Model]
32
+
33
+
34
+ # OpenAI Models
35
+ class OpenAIToolFunction(BaseModel):
36
+ name: str
37
+ arguments: str # JSON string
38
+
39
+
40
+ class OpenAIToolCall(BaseModel):
41
+ id: str
42
+ type: str = "function"
43
+ function: OpenAIToolFunction
44
+
45
+
46
+ class OpenAITool(BaseModel):
47
+ type: str = "function"
48
+ function: Dict[str, Any]
49
+
50
+
51
+ class OpenAIChatMessage(BaseModel):
52
+ role: str
53
+ content: Union[str, List[Dict[str, Any]], None] = None
54
+ reasoning_content: Optional[str] = None
55
+ name: Optional[str] = None
56
+ tool_calls: Optional[List[OpenAIToolCall]] = None
57
+ tool_call_id: Optional[str] = None # for role="tool"
58
+
59
+
60
+ class OpenAIChatCompletionRequest(BaseModel):
61
+ model: str
62
+ messages: List[OpenAIChatMessage]
63
+ stream: bool = False
64
+ temperature: Optional[float] = Field(None, ge=0.0, le=2.0)
65
+ top_p: Optional[float] = Field(None, ge=0.0, le=1.0)
66
+ max_tokens: Optional[int] = Field(None, ge=1)
67
+ stop: Optional[Union[str, List[str]]] = None
68
+ frequency_penalty: Optional[float] = Field(None, ge=-2.0, le=2.0)
69
+ presence_penalty: Optional[float] = Field(None, ge=-2.0, le=2.0)
70
+ n: Optional[int] = Field(1, ge=1, le=128)
71
+ seed: Optional[int] = None
72
+ response_format: Optional[Dict[str, Any]] = None
73
+ top_k: Optional[int] = Field(None, ge=1)
74
+ tools: Optional[List[OpenAITool]] = None
75
+ tool_choice: Optional[Union[str, Dict[str, Any]]] = None
76
+
77
+ class Config:
78
+ extra = "allow" # Allow additional fields not explicitly defined
79
+
80
+
81
+ # 通用的聊天完成请求模型(兼容OpenAI和其他格式)
82
+ ChatCompletionRequest = OpenAIChatCompletionRequest
83
+
84
+
85
+ class OpenAIChatCompletionChoice(BaseModel):
86
+ index: int
87
+ message: OpenAIChatMessage
88
+ finish_reason: Optional[str] = None
89
+ logprobs: Optional[Dict[str, Any]] = None
90
+
91
+
92
+ class OpenAIChatCompletionResponse(BaseModel):
93
+ id: str
94
+ object: str = "chat.completion"
95
+ created: int
96
+ model: str
97
+ choices: List[OpenAIChatCompletionChoice]
98
+ usage: Optional[Dict[str, Any]] = None
99
+ system_fingerprint: Optional[str] = None
100
+
101
+
102
+ class OpenAIDelta(BaseModel):
103
+ role: Optional[str] = None
104
+ content: Optional[str] = None
105
+ reasoning_content: Optional[str] = None
106
+
107
+
108
+ class OpenAIChatCompletionStreamChoice(BaseModel):
109
+ index: int
110
+ delta: OpenAIDelta
111
+ finish_reason: Optional[str] = None
112
+ logprobs: Optional[Dict[str, Any]] = None
113
+
114
+
115
+ class OpenAIChatCompletionStreamResponse(BaseModel):
116
+ id: str
117
+ object: str = "chat.completion.chunk"
118
+ created: int
119
+ model: str
120
+ choices: List[OpenAIChatCompletionStreamChoice]
121
+ system_fingerprint: Optional[str] = None
122
+
123
+
124
+ # Gemini Models
125
+ class GeminiPart(BaseModel):
126
+ text: Optional[str] = None
127
+ inlineData: Optional[Dict[str, Any]] = None
128
+ fileData: Optional[Dict[str, Any]] = None
129
+ thought: Optional[bool] = None # 改为 None,避免序列化时包含 False
130
+
131
+ class Config:
132
+ extra = "allow" # 允许额外字段(如 functionCall, functionResponse)
133
+
134
+
135
+ class GeminiContent(BaseModel):
136
+ role: str
137
+ parts: List[GeminiPart]
138
+
139
+
140
+ class GeminiSystemInstruction(BaseModel):
141
+ parts: List[GeminiPart]
142
+
143
+
144
+ class GeminiImageConfig(BaseModel):
145
+ """图片生成配置"""
146
+ aspect_ratio: Optional[str] = None # "1:1", "2:3", "3:2", "3:4", "4:3", "4:5", "5:4", "9:16", "16:9", "21:9"
147
+ image_size: Optional[str] = None # "1K", "2K", "4K"
148
+
149
+
150
+ class GeminiGenerationConfig(BaseModel):
151
+ temperature: Optional[float] = Field(None, ge=0.0, le=2.0)
152
+ topP: Optional[float] = Field(None, ge=0.0, le=1.0)
153
+ topK: Optional[int] = Field(None, ge=1)
154
+ maxOutputTokens: Optional[int] = Field(None, ge=1)
155
+ stopSequences: Optional[List[str]] = None
156
+ responseMimeType: Optional[str] = None
157
+ responseSchema: Optional[Dict[str, Any]] = None
158
+ candidateCount: Optional[int] = Field(None, ge=1, le=8)
159
+ seed: Optional[int] = None
160
+ frequencyPenalty: Optional[float] = Field(None, ge=-2.0, le=2.0)
161
+ presencePenalty: Optional[float] = Field(None, ge=-2.0, le=2.0)
162
+ thinkingConfig: Optional[Dict[str, Any]] = None
163
+ # 图片生成相关参数
164
+ response_modalities: Optional[List[str]] = None # ["TEXT", "IMAGE"]
165
+ image_config: Optional[GeminiImageConfig] = None
166
+
167
+
168
+ class GeminiSafetySetting(BaseModel):
169
+ category: str
170
+ threshold: str
171
+
172
+
173
+ class GeminiRequest(BaseModel):
174
+ contents: List[GeminiContent]
175
+ systemInstruction: Optional[GeminiSystemInstruction] = None
176
+ generationConfig: Optional[GeminiGenerationConfig] = None
177
+ safetySettings: Optional[List[GeminiSafetySetting]] = None
178
+ tools: Optional[List[Dict[str, Any]]] = None
179
+ toolConfig: Optional[Dict[str, Any]] = None
180
+ cachedContent: Optional[str] = None
181
+
182
+ class Config:
183
+ extra = "allow" # 允许透传未定义的字段
184
+
185
+
186
+ class GeminiCandidate(BaseModel):
187
+ content: GeminiContent
188
+ finishReason: Optional[str] = None
189
+ index: int = 0
190
+ safetyRatings: Optional[List[Dict[str, Any]]] = None
191
+ citationMetadata: Optional[Dict[str, Any]] = None
192
+ tokenCount: Optional[int] = None
193
+
194
+
195
+ class GeminiUsageMetadata(BaseModel):
196
+ promptTokenCount: Optional[int] = None
197
+ candidatesTokenCount: Optional[int] = None
198
+ totalTokenCount: Optional[int] = None
199
+ cachedContentTokenCount: Optional[int] = None
200
+ thoughtsTokenCount: Optional[int] = None
201
+
202
+
203
+ class GeminiResponse(BaseModel):
204
+ candidates: List[GeminiCandidate]
205
+ usageMetadata: Optional[GeminiUsageMetadata] = None
206
+ modelVersion: Optional[str] = None
207
+
208
+
209
+ # Claude Models
210
+ class ClaudeContentBlock(BaseModel):
211
+ type: str # "text", "image", "tool_use", "tool_result"
212
+ text: Optional[str] = None
213
+ source: Optional[Dict[str, Any]] = None # for image type
214
+ id: Optional[str] = None # for tool_use
215
+ name: Optional[str] = None # for tool_use
216
+ input: Optional[Dict[str, Any]] = None # for tool_use
217
+ tool_use_id: Optional[str] = None # for tool_result
218
+ content: Optional[Union[str, List[Dict[str, Any]]]] = None # for tool_result
219
+
220
+
221
+ class ClaudeMessage(BaseModel):
222
+ role: str # "user" or "assistant"
223
+ content: Union[str, List[ClaudeContentBlock]]
224
+
225
+
226
+ class ClaudeTool(BaseModel):
227
+ name: str
228
+ description: Optional[str] = None
229
+ input_schema: Dict[str, Any]
230
+
231
+
232
+ class ClaudeMetadata(BaseModel):
233
+ user_id: Optional[str] = None
234
+
235
+
236
+ class ClaudeRequest(BaseModel):
237
+ model: str
238
+ messages: List[ClaudeMessage]
239
+ max_tokens: int = Field(..., ge=1)
240
+ system: Optional[Union[str, List[Dict[str, Any]]]] = None
241
+ temperature: Optional[float] = Field(None, ge=0.0, le=1.0)
242
+ top_p: Optional[float] = Field(None, ge=0.0, le=1.0)
243
+ top_k: Optional[int] = Field(None, ge=1)
244
+ stop_sequences: Optional[List[str]] = None
245
+ stream: bool = False
246
+ metadata: Optional[ClaudeMetadata] = None
247
+ tools: Optional[List[ClaudeTool]] = None
248
+ tool_choice: Optional[Union[str, Dict[str, Any]]] = None
249
+
250
+ class Config:
251
+ extra = "allow"
252
+
253
+
254
+ class ClaudeUsage(BaseModel):
255
+ input_tokens: int
256
+ output_tokens: int
257
+ cache_creation_input_tokens: Optional[int] = None
258
+ cache_read_input_tokens: Optional[int] = None
259
+
260
+
261
+ class ClaudeResponse(BaseModel):
262
+ id: str
263
+ type: str = "message"
264
+ role: str = "assistant"
265
+ content: List[ClaudeContentBlock]
266
+ model: str
267
+ stop_reason: Optional[str] = None
268
+ stop_sequence: Optional[str] = None
269
+ usage: ClaudeUsage
270
+
271
+
272
+ class ClaudeStreamEvent(BaseModel):
273
+ type: str # "message_start", "content_block_start", "content_block_delta", "content_block_stop", "message_delta", "message_stop"
274
+ message: Optional[ClaudeResponse] = None
275
+ index: Optional[int] = None
276
+ content_block: Optional[ClaudeContentBlock] = None
277
+ delta: Optional[Dict[str, Any]] = None
278
+ usage: Optional[ClaudeUsage] = None
279
+
280
+ class Config:
281
+ extra = "allow"
282
+
283
+
284
+ # Error Models
285
+ class APIError(BaseModel):
286
+ message: str
287
+ type: str = "api_error"
288
+ code: Optional[int] = None
289
+
290
+
291
+ class ErrorResponse(BaseModel):
292
+ error: APIError
293
+
294
+
295
+ # Control Panel Models
296
+ class SystemStatus(BaseModel):
297
+ status: str
298
+ timestamp: str
299
+ credentials: Dict[str, int]
300
+ config: Dict[str, Any]
301
+ current_credential: str
302
+
303
+
304
+ class CredentialInfo(BaseModel):
305
+ filename: str
306
+ project_id: Optional[str] = None
307
+ status: Dict[str, Any]
308
+ size: Optional[int] = None
309
+ modified_time: Optional[str] = None
310
+ error: Optional[str] = None
311
+
312
+
313
+ class LogEntry(BaseModel):
314
+ timestamp: str
315
+ level: str
316
+ message: str
317
+ module: Optional[str] = None
318
+
319
+
320
+ class ConfigValue(BaseModel):
321
+ key: str
322
+ value: Any
323
+ env_locked: bool = False
324
+ description: Optional[str] = None
325
+
326
+
327
+ # Authentication Models
328
+ class AuthRequest(BaseModel):
329
+ project_id: Optional[str] = None
330
+ user_session: Optional[str] = None
331
+
332
+
333
+ class AuthResponse(BaseModel):
334
+ success: bool
335
+ auth_url: Optional[str] = None
336
+ state: Optional[str] = None
337
+ error: Optional[str] = None
338
+ credentials: Optional[Dict[str, Any]] = None
339
+ file_path: Optional[str] = None
340
+ requires_manual_project_id: Optional[bool] = None
341
+ requires_project_selection: Optional[bool] = None
342
+ available_projects: Optional[List[Dict[str, str]]] = None
343
+
344
+
345
+ class CredentialStatus(BaseModel):
346
+ disabled: bool = False
347
+ error_codes: List[int] = []
348
+ last_success: Optional[str] = None
349
+
350
+
351
+ # Web Routes Models
352
+ class LoginRequest(BaseModel):
353
+ password: str
354
+
355
+
356
+ class AuthStartRequest(BaseModel):
357
+ project_id: Optional[str] = None # 现在是可选的
358
+ mode: Optional[str] = "geminicli" # 凭证模式: geminicli 或 antigravity
359
+
360
+
361
+ class AuthCallbackRequest(BaseModel):
362
+ project_id: Optional[str] = None # 现在是可选的
363
+ mode: Optional[str] = "geminicli" # 凭证模式: geminicli 或 antigravity
364
+
365
+
366
+ class AuthCallbackUrlRequest(BaseModel):
367
+ callback_url: str # OAuth回调完整URL
368
+ project_id: Optional[str] = None # 可选的项目ID
369
+ mode: Optional[str] = "geminicli" # 凭证模式: geminicli 或 antigravity
370
+
371
+
372
+ class CredFileActionRequest(BaseModel):
373
+ filename: str
374
+ action: str # enable, disable, delete
375
+
376
+
377
+ class CredFileBatchActionRequest(BaseModel):
378
+ action: str # "enable", "disable", "delete"
379
+ filenames: List[str] # 批量操作的文件名列表
380
+
381
+
382
+ class ConfigSaveRequest(BaseModel):
383
+ config: dict
src/panel/__init__.py ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Panel模块 - 整合所有控制面板路由
3
+ """
4
+
5
+ from fastapi import APIRouter
6
+
7
+ from . import auth, creds, config_routes, logs, version, root
8
+
9
+
10
+ def create_router() -> APIRouter:
11
+ """创建并返回整合所有子路由的主路由器"""
12
+ router = APIRouter()
13
+
14
+ # 包含所有子路由
15
+ router.include_router(root.router)
16
+ router.include_router(auth.router)
17
+ router.include_router(creds.router)
18
+ router.include_router(config_routes.router)
19
+ router.include_router(logs.router)
20
+ router.include_router(version.router)
21
+
22
+ return router
23
+
24
+
25
+ # 导出主路由器
26
+ router = create_router()
27
+
28
+ # 导出常用工具
29
+ from .utils import ConnectionManager, is_mobile_user_agent, validate_mode, get_env_locked_keys
30
+
31
+ __all__ = [
32
+ "router",
33
+ "ConnectionManager",
34
+ "is_mobile_user_agent",
35
+ "validate_mode",
36
+ "get_env_locked_keys",
37
+ ]
src/panel/auth.py ADDED
@@ -0,0 +1,192 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 认证路由模块 - 处理 /auth/* 相关的HTTP请求
3
+ """
4
+
5
+ from fastapi import APIRouter, Depends, HTTPException
6
+ from fastapi.responses import JSONResponse
7
+
8
+ from log import log
9
+ from src.auth import (
10
+ asyncio_complete_auth_flow,
11
+ complete_auth_flow_from_callback_url,
12
+ create_auth_url,
13
+ get_auth_status,
14
+ verify_password,
15
+ )
16
+ from src.models import (
17
+ LoginRequest,
18
+ AuthStartRequest,
19
+ AuthCallbackRequest,
20
+ AuthCallbackUrlRequest,
21
+ )
22
+ from src.utils import verify_panel_token
23
+
24
+
25
+ # 创建路由器
26
+ router = APIRouter(prefix="/auth", tags=["auth"])
27
+
28
+
29
+ @router.post("/login")
30
+ async def login(request: LoginRequest):
31
+ """用户登录(简化版:直接返回密码作为token)"""
32
+ try:
33
+ if await verify_password(request.password):
34
+ # 直接使用密码作为token,简化认证流程
35
+ return JSONResponse(content={"token": request.password, "message": "登录成功"})
36
+ else:
37
+ raise HTTPException(status_code=401, detail="密码错误")
38
+ except HTTPException:
39
+ raise
40
+ except Exception as e:
41
+ log.error(f"登录失败: {e}")
42
+ raise HTTPException(status_code=500, detail=str(e))
43
+
44
+
45
+ @router.post("/start")
46
+ async def start_auth(request: AuthStartRequest, token: str = Depends(verify_panel_token)):
47
+ """开始认证流程,支持自动检测项目ID"""
48
+ try:
49
+ # 如果没有提供项目ID,尝试自动检测
50
+ project_id = request.project_id
51
+ if not project_id:
52
+ log.info("用户未提供项目ID,后续将使用自动检测...")
53
+
54
+ # 使用认证令牌作为用户会话标识
55
+ user_session = token if token else None
56
+ result = await create_auth_url(
57
+ project_id, user_session, mode=request.mode
58
+ )
59
+
60
+ if result["success"]:
61
+ return JSONResponse(
62
+ content={
63
+ "auth_url": result["auth_url"],
64
+ "state": result["state"],
65
+ "auto_project_detection": result.get("auto_project_detection", False),
66
+ "detected_project_id": result.get("detected_project_id"),
67
+ }
68
+ )
69
+ else:
70
+ raise HTTPException(status_code=500, detail=result["error"])
71
+
72
+ except HTTPException:
73
+ raise
74
+ except Exception as e:
75
+ log.error(f"开始认证流程失败: {e}")
76
+ raise HTTPException(status_code=500, detail=str(e))
77
+
78
+
79
+ @router.post("/callback")
80
+ async def auth_callback(request: AuthCallbackRequest, token: str = Depends(verify_panel_token)):
81
+ """处理认证回调,支持自动检测项目ID"""
82
+ try:
83
+ # 项目ID现在是可选的,在回调处理中进行自动检测
84
+ project_id = request.project_id
85
+
86
+ # 使用认证令牌作为用户会话标识
87
+ user_session = token if token else None
88
+ # 异步等待OAuth回调完成
89
+ result = await asyncio_complete_auth_flow(
90
+ project_id, user_session, mode=request.mode
91
+ )
92
+
93
+ if result["success"]:
94
+ # 单项目认证成功
95
+ return JSONResponse(
96
+ content={
97
+ "credentials": result["credentials"],
98
+ "file_path": result["file_path"],
99
+ "message": "认证成功,凭证已保存",
100
+ "auto_detected_project": result.get("auto_detected_project", False),
101
+ }
102
+ )
103
+ else:
104
+ # 如果需要手动项目ID或项目选择,在响应中标明
105
+ if result.get("requires_manual_project_id"):
106
+ # 使用JSON响应
107
+ return JSONResponse(
108
+ status_code=400,
109
+ content={"error": result["error"], "requires_manual_project_id": True},
110
+ )
111
+ elif result.get("requires_project_selection"):
112
+ # 返回项目列表供用户选择
113
+ return JSONResponse(
114
+ status_code=400,
115
+ content={
116
+ "error": result["error"],
117
+ "requires_project_selection": True,
118
+ "available_projects": result["available_projects"],
119
+ },
120
+ )
121
+ else:
122
+ raise HTTPException(status_code=400, detail=result["error"])
123
+
124
+ except HTTPException:
125
+ raise
126
+ except Exception as e:
127
+ log.error(f"处理认证回调失败: {e}")
128
+ raise HTTPException(status_code=500, detail=str(e))
129
+
130
+
131
+ @router.post("/callback-url")
132
+ async def auth_callback_url(request: AuthCallbackUrlRequest, token: str = Depends(verify_panel_token)):
133
+ """从回调URL直接完成认证"""
134
+ try:
135
+ # 验证URL格式
136
+ if not request.callback_url or not request.callback_url.startswith(("http://", "https://")):
137
+ raise HTTPException(status_code=400, detail="请提供有效的回调URL")
138
+
139
+ # 从回调URL完成认证
140
+ result = await complete_auth_flow_from_callback_url(
141
+ request.callback_url, request.project_id, mode=request.mode
142
+ )
143
+
144
+ if result["success"]:
145
+ # 单项目认证成功
146
+ return JSONResponse(
147
+ content={
148
+ "credentials": result["credentials"],
149
+ "file_path": result["file_path"],
150
+ "message": "从回调URL认证成功,凭证已保存",
151
+ "auto_detected_project": result.get("auto_detected_project", False),
152
+ }
153
+ )
154
+ else:
155
+ # 处理各种错误情况
156
+ if result.get("requires_manual_project_id"):
157
+ return JSONResponse(
158
+ status_code=400,
159
+ content={"error": result["error"], "requires_manual_project_id": True},
160
+ )
161
+ elif result.get("requires_project_selection"):
162
+ return JSONResponse(
163
+ status_code=400,
164
+ content={
165
+ "error": result["error"],
166
+ "requires_project_selection": True,
167
+ "available_projects": result["available_projects"],
168
+ },
169
+ )
170
+ else:
171
+ raise HTTPException(status_code=400, detail=result["error"])
172
+
173
+ except HTTPException:
174
+ raise
175
+ except Exception as e:
176
+ log.error(f"从回调URL处理认证失败: {e}")
177
+ raise HTTPException(status_code=500, detail=str(e))
178
+
179
+
180
+ @router.get("/status/{project_id}")
181
+ async def check_auth_status(project_id: str, token: str = Depends(verify_panel_token)):
182
+ """检查认证状态"""
183
+ try:
184
+ if not project_id:
185
+ raise HTTPException(status_code=400, detail="Project ID 不能为空")
186
+
187
+ status = get_auth_status(project_id)
188
+ return JSONResponse(content=status)
189
+
190
+ except Exception as e:
191
+ log.error(f"检查认证状态失败: {e}")
192
+ raise HTTPException(status_code=500, detail=str(e))
src/panel/config_routes.py ADDED
@@ -0,0 +1,230 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 配置路由模块 - 处理 /config/* 相关的HTTP请求
3
+ """
4
+
5
+ from fastapi import APIRouter, Depends, HTTPException
6
+ from fastapi.responses import JSONResponse
7
+
8
+ import config
9
+ from log import log
10
+ from src.keeplive import keepalive_service
11
+ from src.models import ConfigSaveRequest
12
+ from src.storage_adapter import get_storage_adapter
13
+ from src.utils import verify_panel_token
14
+ from .utils import get_env_locked_keys
15
+
16
+
17
+ # 创建路由器
18
+ router = APIRouter(prefix="/config", tags=["config"])
19
+
20
+
21
+ @router.get("/get")
22
+ async def get_config(token: str = Depends(verify_panel_token)):
23
+ """获取当前配置"""
24
+ try:
25
+
26
+
27
+ # 读取当前配置(包括环境变量和TOML文件中的配置)
28
+ current_config = {}
29
+
30
+ # 基础配置
31
+ current_config["code_assist_endpoint"] = await config.get_code_assist_endpoint()
32
+ current_config["credentials_dir"] = await config.get_credentials_dir()
33
+ current_config["proxy"] = await config.get_proxy_config() or ""
34
+
35
+ # 代理端点配置
36
+ current_config["oauth_proxy_url"] = await config.get_oauth_proxy_url()
37
+ current_config["googleapis_proxy_url"] = await config.get_googleapis_proxy_url()
38
+ current_config["resource_manager_api_url"] = await config.get_resource_manager_api_url()
39
+ current_config["service_usage_api_url"] = await config.get_service_usage_api_url()
40
+ current_config["antigravity_api_url"] = await config.get_antigravity_api_url()
41
+
42
+ # 自动封禁配置
43
+ current_config["auto_ban_enabled"] = await config.get_auto_ban_enabled()
44
+ current_config["auto_ban_error_codes"] = await config.get_auto_ban_error_codes()
45
+
46
+ # 429重试配置
47
+ current_config["retry_429_max_retries"] = await config.get_retry_429_max_retries()
48
+ current_config["retry_429_enabled"] = await config.get_retry_429_enabled()
49
+ current_config["retry_429_interval"] = await config.get_retry_429_interval()
50
+ # 抗截断配置
51
+ current_config["anti_truncation_max_attempts"] = await config.get_anti_truncation_max_attempts()
52
+
53
+ # 兼容性配置
54
+ current_config["compatibility_mode_enabled"] = await config.get_compatibility_mode_enabled()
55
+
56
+ # 思维链返回配置
57
+ current_config["return_thoughts_to_frontend"] = await config.get_return_thoughts_to_frontend()
58
+
59
+ # Antigravity流式转非流式配置
60
+ current_config["antigravity_stream2nostream"] = await config.get_antigravity_stream2nostream()
61
+ current_config["antigravity_switch_credential_enabled"] = await config.get_antigravity_switch_credential_enabled()
62
+
63
+ # 保活配置
64
+ current_config["keepalive_url"] = await config.get_keepalive_url()
65
+ current_config["keepalive_interval"] = await config.get_keepalive_interval()
66
+
67
+ # 服务器配置
68
+ current_config["host"] = await config.get_server_host()
69
+ current_config["port"] = await config.get_server_port()
70
+ current_config["api_password"] = await config.get_api_password()
71
+ current_config["panel_password"] = await config.get_panel_password()
72
+ current_config["password"] = await config.get_server_password()
73
+
74
+ # 从存储系统读取配置
75
+ storage_adapter = await get_storage_adapter()
76
+ storage_config = await storage_adapter.get_all_config()
77
+
78
+ # 获取环境变量锁定的配置键
79
+ env_locked_keys = get_env_locked_keys()
80
+
81
+ # 合并存储系统配置(不覆盖环境变量)
82
+ for key, value in storage_config.items():
83
+ if key not in env_locked_keys:
84
+ current_config[key] = value
85
+
86
+ return JSONResponse(content={"config": current_config, "env_locked": list(env_locked_keys)})
87
+
88
+ except Exception as e:
89
+ log.error(f"获取配置失败: {e}")
90
+ raise HTTPException(status_code=500, detail=str(e))
91
+
92
+
93
+ @router.post("/save")
94
+ async def save_config(request: ConfigSaveRequest, token: str = Depends(verify_panel_token)):
95
+ """保存配置"""
96
+ try:
97
+
98
+ new_config = request.config
99
+
100
+ log.debug(f"收到的配置数据: {list(new_config.keys())}")
101
+ log.debug(f"收到的password值: {new_config.get('password', 'NOT_FOUND')}")
102
+
103
+ # 验证配置项
104
+ if "retry_429_max_retries" in new_config:
105
+ if (
106
+ not isinstance(new_config["retry_429_max_retries"], int)
107
+ or new_config["retry_429_max_retries"] < 0
108
+ ):
109
+ raise HTTPException(status_code=400, detail="最大429重试次数必须是大于等于0的整数")
110
+
111
+ if "retry_429_enabled" in new_config:
112
+ if not isinstance(new_config["retry_429_enabled"], bool):
113
+ raise HTTPException(status_code=400, detail="429重试开关必须是布尔值")
114
+
115
+ # 验证新的配置项
116
+ if "retry_429_interval" in new_config:
117
+ try:
118
+ interval = float(new_config["retry_429_interval"])
119
+ if interval < 0.01 or interval > 10:
120
+ raise HTTPException(status_code=400, detail="429重试间隔必须在0.01-10秒之间")
121
+ except (ValueError, TypeError):
122
+ raise HTTPException(status_code=400, detail="429重试间隔必须是有效的数字")
123
+
124
+ if "anti_truncation_max_attempts" in new_config:
125
+ if (
126
+ not isinstance(new_config["anti_truncation_max_attempts"], int)
127
+ or new_config["anti_truncation_max_attempts"] < 1
128
+ or new_config["anti_truncation_max_attempts"] > 10
129
+ ):
130
+ raise HTTPException(
131
+ status_code=400, detail="抗截断最大重试次数必须是1-10之间的整数"
132
+ )
133
+
134
+ if "compatibility_mode_enabled" in new_config:
135
+ if not isinstance(new_config["compatibility_mode_enabled"], bool):
136
+ raise HTTPException(status_code=400, detail="兼容性模式开关必须是布尔值")
137
+
138
+ if "return_thoughts_to_frontend" in new_config:
139
+ if not isinstance(new_config["return_thoughts_to_frontend"], bool):
140
+ raise HTTPException(status_code=400, detail="思维链返回开关必须是布尔值")
141
+
142
+ if "antigravity_stream2nostream" in new_config:
143
+ if not isinstance(new_config["antigravity_stream2nostream"], bool):
144
+ raise HTTPException(status_code=400, detail="Antigravity流式转非流式开关必须是布尔值")
145
+
146
+ if "antigravity_switch_credential_enabled" in new_config:
147
+ if not isinstance(new_config["antigravity_switch_credential_enabled"], bool):
148
+ raise HTTPException(status_code=400, detail="Antigravity切换凭证开关必须是布尔值")
149
+
150
+ # 验证保活配置
151
+ if "keepalive_url" in new_config:
152
+ if not isinstance(new_config["keepalive_url"], str):
153
+ raise HTTPException(status_code=400, detail="保活URL必须是字符串")
154
+
155
+ if "keepalive_interval" in new_config:
156
+ try:
157
+ interval = int(new_config["keepalive_interval"])
158
+ if interval < 5 or interval > 86400:
159
+ raise HTTPException(status_code=400, detail="保活间隔必须在 5-86400 秒之间")
160
+ new_config["keepalive_interval"] = interval
161
+ except (ValueError, TypeError):
162
+ raise HTTPException(status_code=400, detail="保活间隔必须是有效整数")
163
+ # 验证服务器配置
164
+ if "host" in new_config:
165
+ if not isinstance(new_config["host"], str) or not new_config["host"].strip():
166
+ raise HTTPException(status_code=400, detail="服务器主机地址不能为空")
167
+
168
+ if "port" in new_config:
169
+ if (
170
+ not isinstance(new_config["port"], int)
171
+ or new_config["port"] < 1
172
+ or new_config["port"] > 65535
173
+ ):
174
+ raise HTTPException(status_code=400, detail="端口号必须是1-65535之间的整数")
175
+
176
+ if "api_password" in new_config:
177
+ if not isinstance(new_config["api_password"], str):
178
+ raise HTTPException(status_code=400, detail="API访问密码必须是字符串")
179
+
180
+ if "panel_password" in new_config:
181
+ if not isinstance(new_config["panel_password"], str):
182
+ raise HTTPException(status_code=400, detail="控制面板密码必须是字符串")
183
+
184
+ if "password" in new_config:
185
+ if not isinstance(new_config["password"], str):
186
+ raise HTTPException(status_code=400, detail="访问密码必须是字符串")
187
+
188
+ # 获取环境变量锁定的配置键
189
+ env_locked_keys = get_env_locked_keys()
190
+
191
+ # 直接使用存储适配器保存配置
192
+ storage_adapter = await get_storage_adapter()
193
+ for key, value in new_config.items():
194
+ if key not in env_locked_keys:
195
+ await storage_adapter.set_config(key, value)
196
+ if key in ("password", "api_password", "panel_password"):
197
+ log.debug(f"设置{key}字段为: {value}")
198
+
199
+ # 重新加载配置缓存(关键!)
200
+ await config.reload_config()
201
+
202
+ # 如果保活相关配置发生变化,立即重启保活服务
203
+ keepalive_keys = {"keepalive_url", "keepalive_interval"}
204
+ if keepalive_keys & set(new_config.keys()):
205
+ try:
206
+ await keepalive_service.restart()
207
+ except Exception as e:
208
+ log.warning(f"重启保活服务失败: {e}")
209
+
210
+ # 验证保存后的结果
211
+ test_api_password = await config.get_api_password()
212
+ test_panel_password = await config.get_panel_password()
213
+ test_password = await config.get_server_password()
214
+ log.debug(f"保存后立即读取的API密码: {test_api_password}")
215
+ log.debug(f"保存后立即读取的面板密码: {test_panel_password}")
216
+ log.debug(f"保存后立即读取的通用密码: {test_password}")
217
+
218
+ # 构建���应消息
219
+ response_data = {
220
+ "message": "配置保存成功",
221
+ "saved_config": {k: v for k, v in new_config.items() if k not in env_locked_keys},
222
+ }
223
+
224
+ return JSONResponse(content=response_data)
225
+
226
+ except HTTPException:
227
+ raise
228
+ except Exception as e:
229
+ log.error(f"保存配置失败: {e}")
230
+ raise HTTPException(status_code=500, detail=str(e))
src/panel/creds.py ADDED
@@ -0,0 +1,1585 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 凭证管理路由模块 - 处理 /creds/* 相关的HTTP请求
3
+ """
4
+
5
+ import asyncio
6
+ import io
7
+ import json
8
+ import os
9
+ import time
10
+ import zipfile
11
+ from typing import Any, List
12
+
13
+ from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, Response
14
+ from fastapi.responses import JSONResponse
15
+
16
+ from log import log
17
+ from src.credential_manager import credential_manager
18
+ from src.models import (
19
+ CredFileActionRequest,
20
+ CredFileBatchActionRequest
21
+ )
22
+ from src.storage_adapter import get_storage_adapter
23
+ from src.utils import verify_panel_token, GEMINICLI_USER_AGENT, ANTIGRAVITY_USER_AGENT
24
+ from src.api.antigravity import fetch_quota_info
25
+ from src.google_oauth_api import Credentials, fetch_project_id_and_tier
26
+ from config import get_code_assist_endpoint, get_antigravity_api_url
27
+ from .utils import validate_mode
28
+
29
+
30
+ # 创建路由器
31
+ router = APIRouter(prefix="/creds", tags=["credentials"])
32
+
33
+
34
+ # =============================================================================
35
+ # 工具函数 (Helper Functions)
36
+ # =============================================================================
37
+
38
+
39
+ async def extract_json_files_from_zip(zip_file: UploadFile) -> List[dict]:
40
+ """从ZIP文件中提取JSON文件"""
41
+ try:
42
+ # 读取ZIP文件内容
43
+ zip_content = await zip_file.read()
44
+
45
+ # 不限制ZIP文件大小,只在处理时控制文件数量
46
+
47
+ files_data = []
48
+
49
+ with zipfile.ZipFile(io.BytesIO(zip_content), "r") as zip_ref:
50
+ # 获取ZIP中的所有文件
51
+ file_list = zip_ref.namelist()
52
+ json_files = [
53
+ f for f in file_list if f.endswith(".json") and not f.startswith("__MACOSX/")
54
+ ]
55
+
56
+ if not json_files:
57
+ raise HTTPException(status_code=400, detail="ZIP文件中没有找到JSON文件")
58
+
59
+ log.info(f"从ZIP文件 {zip_file.filename} 中找到 {len(json_files)} 个JSON文件")
60
+
61
+ for json_filename in json_files:
62
+ try:
63
+ # 读取JSON文件内容
64
+ with zip_ref.open(json_filename) as json_file:
65
+ content = json_file.read()
66
+
67
+ try:
68
+ content_str = content.decode("utf-8")
69
+ except UnicodeDecodeError:
70
+ log.warning(f"跳过编码错误的文件: {json_filename}")
71
+ continue
72
+
73
+ # 使用原始文件名(去掉路径)
74
+ filename = os.path.basename(json_filename)
75
+ files_data.append({"filename": filename, "content": content_str})
76
+
77
+ except Exception as e:
78
+ log.warning(f"处理ZIP中的文件 {json_filename} 时出错: {e}")
79
+ continue
80
+
81
+ log.info(f"成功从ZIP文件中提取 {len(files_data)} 个有效的JSON文件")
82
+ return files_data
83
+
84
+ except zipfile.BadZipFile:
85
+ raise HTTPException(status_code=400, detail="无效的ZIP文件格式")
86
+ except Exception as e:
87
+ log.error(f"处理ZIP文件失败: {e}")
88
+ raise HTTPException(status_code=500, detail=f"处理ZIP文件失败: {str(e)}")
89
+
90
+
91
+ async def clear_all_model_cooldowns_for_credential(
92
+ storage_adapter: Any,
93
+ filename: str,
94
+ mode: str,
95
+ ) -> None:
96
+ """清空指定凭证的所有模型冷却(后端支持时执行)。"""
97
+ try:
98
+ cleared = await storage_adapter._backend.clear_all_model_cooldowns(filename, mode=mode)
99
+ if not cleared:
100
+ log.warning(f"清空模型CD失败或凭证不存在: {filename} (mode={mode})")
101
+ except Exception as e:
102
+ log.warning(f"清空模型CD时出错: {filename} (mode={mode}), error={e}")
103
+
104
+
105
+ async def upload_credentials_common(
106
+ files: List[UploadFile], mode: str = "geminicli"
107
+ ) -> JSONResponse:
108
+ """批量上传凭证文件的通用函数"""
109
+ mode = validate_mode(mode)
110
+
111
+ if not files:
112
+ raise HTTPException(status_code=400, detail="请选择要上传的文件")
113
+
114
+ # 检查文件数量限制
115
+ if len(files) > 100:
116
+ raise HTTPException(
117
+ status_code=400, detail=f"文件数量过多,最多支持100个文件,当前:{len(files)}个"
118
+ )
119
+
120
+ files_data = []
121
+ for file in files:
122
+ # 检查文件类型:支持JSON和ZIP
123
+ if file.filename.endswith(".zip"):
124
+ zip_files_data = await extract_json_files_from_zip(file)
125
+ files_data.extend(zip_files_data)
126
+ log.info(f"从ZIP文件 {file.filename} 中提取了 {len(zip_files_data)} 个JSON文件")
127
+
128
+ elif file.filename.endswith(".json"):
129
+ # 处理单个JSON文件 - 流式读取
130
+ content_chunks = []
131
+ while True:
132
+ chunk = await file.read(8192)
133
+ if not chunk:
134
+ break
135
+ content_chunks.append(chunk)
136
+
137
+ content = b"".join(content_chunks)
138
+ try:
139
+ content_str = content.decode("utf-8")
140
+ except UnicodeDecodeError:
141
+ raise HTTPException(
142
+ status_code=400, detail=f"文件 {file.filename} 编码格式不支持"
143
+ )
144
+
145
+ files_data.append({"filename": file.filename, "content": content_str})
146
+ else:
147
+ raise HTTPException(
148
+ status_code=400, detail=f"文件 {file.filename} 格式不支持,只支持JSON和ZIP文件"
149
+ )
150
+
151
+
152
+
153
+ batch_size = 1000
154
+ all_results = []
155
+ total_success = 0
156
+
157
+ for i in range(0, len(files_data), batch_size):
158
+ batch_files = files_data[i : i + batch_size]
159
+
160
+ async def process_single_file(file_data):
161
+ try:
162
+ filename = file_data["filename"]
163
+ # 确保文件名只保存basename,避免路径问题
164
+ filename = os.path.basename(filename)
165
+ content_str = file_data["content"]
166
+ credential_data = json.loads(content_str)
167
+
168
+ # 根据凭证类型调用不同的添加方法
169
+ if mode == "antigravity":
170
+ await credential_manager.add_antigravity_credential(filename, credential_data)
171
+ else:
172
+ await credential_manager.add_credential(filename, credential_data)
173
+
174
+ log.debug(f"成功上传 {mode} 凭证文件: {filename}")
175
+ return {"filename": filename, "status": "success", "message": "上传成功"}
176
+
177
+ except json.JSONDecodeError as e:
178
+ return {
179
+ "filename": file_data["filename"],
180
+ "status": "error",
181
+ "message": f"JSON格式错误: {str(e)}",
182
+ }
183
+ except Exception as e:
184
+ return {
185
+ "filename": file_data["filename"],
186
+ "status": "error",
187
+ "message": f"处理失败: {str(e)}",
188
+ }
189
+
190
+ log.info(f"开始并发处理 {len(batch_files)} 个 {mode} 文件...")
191
+ concurrent_tasks = [process_single_file(file_data) for file_data in batch_files]
192
+ batch_results = await asyncio.gather(*concurrent_tasks, return_exceptions=True)
193
+
194
+ processed_results = []
195
+ batch_uploaded_count = 0
196
+ for result in batch_results:
197
+ if isinstance(result, Exception):
198
+ processed_results.append(
199
+ {
200
+ "filename": "unknown",
201
+ "status": "error",
202
+ "message": f"处理异常: {str(result)}",
203
+ }
204
+ )
205
+ else:
206
+ processed_results.append(result)
207
+ if result["status"] == "success":
208
+ batch_uploaded_count += 1
209
+
210
+ all_results.extend(processed_results)
211
+ total_success += batch_uploaded_count
212
+
213
+ batch_num = (i // batch_size) + 1
214
+ total_batches = (len(files_data) + batch_size - 1) // batch_size
215
+ log.info(
216
+ f"批次 {batch_num}/{total_batches} 完成: 成功 "
217
+ f"{batch_uploaded_count}/{len(batch_files)} 个 {mode} 文件"
218
+ )
219
+
220
+ if total_success > 0:
221
+ return JSONResponse(
222
+ content={
223
+ "uploaded_count": total_success,
224
+ "total_count": len(files_data),
225
+ "results": all_results,
226
+ "message": f"批量上传完成: 成功 {total_success}/{len(files_data)} 个 {mode} 文件",
227
+ }
228
+ )
229
+ else:
230
+ raise HTTPException(status_code=400, detail=f"没有 {mode} 文件上传成功")
231
+
232
+
233
+ async def get_creds_status_common(
234
+ offset: int, limit: int, status_filter: str, mode: str = "geminicli",
235
+ error_code_filter: str = None, cooldown_filter: str = None, preview_filter: str = None, tier_filter: str = None
236
+ ) -> JSONResponse:
237
+ """获取凭证文件状态的通用函数"""
238
+ mode = validate_mode(mode)
239
+ # 验证分页参数
240
+ if offset < 0:
241
+ raise HTTPException(status_code=400, detail="offset 必须大于等于 0")
242
+ if limit not in [20, 50, 100, 200, 500, 1000]:
243
+ raise HTTPException(status_code=400, detail="limit 只能是 20、50、100、200、500 或 1000")
244
+ if status_filter not in ["all", "enabled", "disabled"]:
245
+ raise HTTPException(status_code=400, detail="status_filter 只能是 all、enabled 或 disabled")
246
+ if cooldown_filter and cooldown_filter not in ["all", "in_cooldown", "no_cooldown"]:
247
+ raise HTTPException(status_code=400, detail="cooldown_filter 只能是 all、in_cooldown 或 no_cooldown")
248
+ if preview_filter and preview_filter not in ["all", "preview", "no_preview"]:
249
+ raise HTTPException(status_code=400, detail="preview_filter 只能是 all、preview 或 no_preview")
250
+ if tier_filter and tier_filter not in ["all", "free", "pro", "ultra"]:
251
+ raise HTTPException(status_code=400, detail="tier_filter 只能是 all、free、pro 或 ultra")
252
+
253
+
254
+
255
+ storage_adapter = await get_storage_adapter()
256
+ backend_info = await storage_adapter.get_backend_info()
257
+ backend_type = backend_info.get("backend_type", "unknown")
258
+
259
+ # 使用高性能的分页摘要查询
260
+ result = await storage_adapter._backend.get_credentials_summary(
261
+ offset=offset,
262
+ limit=limit,
263
+ status_filter=status_filter,
264
+ mode=mode,
265
+ error_code_filter=error_code_filter if error_code_filter and error_code_filter != "all" else None,
266
+ cooldown_filter=cooldown_filter if cooldown_filter and cooldown_filter != "all" else None,
267
+ preview_filter=preview_filter if preview_filter and preview_filter != "all" else None,
268
+ tier_filter=tier_filter if tier_filter and tier_filter != "all" else None
269
+ )
270
+
271
+ creds_list = []
272
+ for summary in result["items"]:
273
+ cred_info = {
274
+ "filename": os.path.basename(summary["filename"]),
275
+ "user_email": summary["user_email"],
276
+ "disabled": summary["disabled"],
277
+ "error_codes": summary["error_codes"],
278
+ "last_success": summary["last_success"],
279
+ "backend_type": backend_type,
280
+ "model_cooldowns": summary.get("model_cooldowns", {}),
281
+ "tier": summary.get("tier", "pro"),
282
+ }
283
+
284
+ if mode == "geminicli":
285
+ cred_info["preview"] = summary.get("preview", True)
286
+ else:
287
+ cred_info["enable_credit"] = summary.get("enable_credit", False)
288
+
289
+ creds_list.append(cred_info)
290
+
291
+ return JSONResponse(content={
292
+ "items": creds_list,
293
+ "total": result["total"],
294
+ "offset": offset,
295
+ "limit": limit,
296
+ "has_more": (offset + limit) < result["total"],
297
+ "stats": result.get("stats", {"total": 0, "normal": 0, "disabled": 0}),
298
+ })
299
+
300
+
301
+ async def download_all_creds_common(mode: str = "geminicli") -> Response:
302
+ """打包下载所有凭证文件的通用函数"""
303
+ mode = validate_mode(mode)
304
+ zip_filename = "antigravity_credentials.zip" if mode == "antigravity" else "credentials.zip"
305
+
306
+ storage_adapter = await get_storage_adapter()
307
+ credential_filenames = await storage_adapter.list_credentials(mode=mode)
308
+
309
+ if not credential_filenames:
310
+ raise HTTPException(status_code=404, detail=f"没有找到 {mode} 凭证文件")
311
+
312
+ log.info(f"开始打包 {len(credential_filenames)} 个 {mode} 凭证文件...")
313
+
314
+ zip_buffer = io.BytesIO()
315
+
316
+ with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zip_file:
317
+ success_count = 0
318
+ for idx, filename in enumerate(credential_filenames, 1):
319
+ try:
320
+ credential_data = await storage_adapter.get_credential(filename, mode=mode)
321
+ if credential_data:
322
+ content = json.dumps(credential_data, ensure_ascii=False, indent=2)
323
+ zip_file.writestr(os.path.basename(filename), content)
324
+ success_count += 1
325
+
326
+ if idx % 10 == 0:
327
+ log.debug(f"打包进度: {idx}/{len(credential_filenames)}")
328
+
329
+ except Exception as e:
330
+ log.warning(f"处理 {mode} 凭证文件 {filename} 时出错: {e}")
331
+ continue
332
+
333
+ log.info(f"打包完成: 成功 {success_count}/{len(credential_filenames)} 个文件")
334
+
335
+ zip_buffer.seek(0)
336
+ return Response(
337
+ content=zip_buffer.getvalue(),
338
+ media_type="application/zip",
339
+ headers={"Content-Disposition": f"attachment; filename={zip_filename}"},
340
+ )
341
+
342
+
343
+ async def fetch_user_email_common(filename: str, mode: str = "geminicli") -> JSONResponse:
344
+ """获取指定凭证文件用户邮箱的通用函数"""
345
+ mode = validate_mode(mode)
346
+
347
+ filename_only = os.path.basename(filename)
348
+ if not filename_only.endswith(".json"):
349
+ raise HTTPException(status_code=404, detail="无效的文件名")
350
+
351
+ storage_adapter = await get_storage_adapter()
352
+ credential_data = await storage_adapter.get_credential(filename_only, mode=mode)
353
+ if not credential_data:
354
+ raise HTTPException(status_code=404, detail="凭证文件不存在")
355
+
356
+ email = await credential_manager.get_or_fetch_user_email(filename_only, mode=mode)
357
+
358
+ if email:
359
+ return JSONResponse(
360
+ content={
361
+ "filename": filename_only,
362
+ "user_email": email,
363
+ "message": "成功获取用户邮箱",
364
+ }
365
+ )
366
+ else:
367
+ return JSONResponse(
368
+ content={
369
+ "filename": filename_only,
370
+ "user_email": None,
371
+ "message": "无法获取用户邮箱,可能凭证已过期或权限不足",
372
+ },
373
+ status_code=400,
374
+ )
375
+
376
+
377
+ async def refresh_all_user_emails_common(mode: str = "geminicli") -> JSONResponse:
378
+ """刷新所有凭证文件用户邮箱的通用函数 - 只为没有邮箱的凭证获取
379
+
380
+ 利用 get_all_credential_states 批量获取状态
381
+ """
382
+ mode = validate_mode(mode)
383
+
384
+ storage_adapter = await get_storage_adapter()
385
+
386
+ # 一次性批量获取所有凭证的状态
387
+ all_states = await storage_adapter.get_all_credential_states(mode=mode)
388
+
389
+ results = []
390
+ success_count = 0
391
+ skipped_count = 0
392
+
393
+ # 在内存中筛选出需要获取邮箱的凭证
394
+ for filename, state in all_states.items():
395
+ try:
396
+ cached_email = state.get("user_email")
397
+
398
+ if cached_email:
399
+ # 已有邮箱,跳过获取
400
+ skipped_count += 1
401
+ results.append({
402
+ "filename": os.path.basename(filename),
403
+ "user_email": cached_email,
404
+ "success": True,
405
+ "skipped": True,
406
+ })
407
+ continue
408
+
409
+ # 没有邮箱,尝试获取
410
+ email = await credential_manager.get_or_fetch_user_email(filename, mode=mode)
411
+ if email:
412
+ success_count += 1
413
+ results.append({
414
+ "filename": os.path.basename(filename),
415
+ "user_email": email,
416
+ "success": True,
417
+ })
418
+ else:
419
+ results.append({
420
+ "filename": os.path.basename(filename),
421
+ "user_email": None,
422
+ "success": False,
423
+ "error": "无法获取邮箱",
424
+ })
425
+ except Exception as e:
426
+ results.append({
427
+ "filename": os.path.basename(filename),
428
+ "user_email": None,
429
+ "success": False,
430
+ "error": str(e),
431
+ })
432
+
433
+ total_count = len(all_states)
434
+ return JSONResponse(
435
+ content={
436
+ "success_count": success_count,
437
+ "total_count": total_count,
438
+ "skipped_count": skipped_count,
439
+ "results": results,
440
+ "message": f"成功获取 {success_count}/{total_count} 个邮箱地址,跳过 {skipped_count} 个已有邮箱的凭证",
441
+ }
442
+ )
443
+
444
+
445
+ async def deduplicate_credentials_by_email_common(mode: str = "geminicli") -> JSONResponse:
446
+ """批量去重凭证文件的通用函数 - 删除邮箱相同的凭证(只保留一个)"""
447
+ mode = validate_mode(mode)
448
+ storage_adapter = await get_storage_adapter()
449
+
450
+ try:
451
+ duplicate_info = await storage_adapter._backend.get_duplicate_credentials_by_email(
452
+ mode=mode
453
+ )
454
+
455
+ duplicate_groups = duplicate_info.get("duplicate_groups", [])
456
+ no_email_files = duplicate_info.get("no_email_files", [])
457
+ total_count = duplicate_info.get("total_count", 0)
458
+
459
+ if not duplicate_groups:
460
+ return JSONResponse(
461
+ content={
462
+ "deleted_count": 0,
463
+ "kept_count": total_count,
464
+ "total_count": total_count,
465
+ "unique_emails_count": duplicate_info.get("unique_email_count", 0),
466
+ "no_email_count": len(no_email_files),
467
+ "duplicate_groups": [],
468
+ "delete_errors": [],
469
+ "message": "没有发现重复的凭证(相同邮箱)",
470
+ }
471
+ )
472
+
473
+ # 执行删除操作
474
+ deleted_count = 0
475
+ delete_errors = []
476
+ result_duplicate_groups = []
477
+
478
+ for group in duplicate_groups:
479
+ email = group["email"]
480
+ kept_file = group["kept_file"]
481
+ duplicate_files = group["duplicate_files"]
482
+
483
+ deleted_files_in_group = []
484
+ for filename in duplicate_files:
485
+ try:
486
+ success = await credential_manager.remove_credential(filename, mode=mode)
487
+ if success:
488
+ deleted_count += 1
489
+ deleted_files_in_group.append(os.path.basename(filename))
490
+ log.info(f"去重删除凭证: {filename} (邮箱: {email}) (mode={mode})")
491
+ else:
492
+ delete_errors.append(f"{os.path.basename(filename)}: 删除失败")
493
+ except Exception as e:
494
+ delete_errors.append(f"{os.path.basename(filename)}: {str(e)}")
495
+ log.error(f"去重删除凭证 {filename} 时出错: {e}")
496
+
497
+ result_duplicate_groups.append({
498
+ "email": email,
499
+ "kept_file": os.path.basename(kept_file),
500
+ "deleted_files": deleted_files_in_group,
501
+ "duplicate_count": len(deleted_files_in_group),
502
+ })
503
+
504
+ kept_count = total_count - deleted_count
505
+
506
+ return JSONResponse(
507
+ content={
508
+ "deleted_count": deleted_count,
509
+ "kept_count": kept_count,
510
+ "total_count": total_count,
511
+ "unique_emails_count": duplicate_info.get("unique_email_count", 0),
512
+ "no_email_count": len(no_email_files),
513
+ "duplicate_groups": result_duplicate_groups,
514
+ "delete_errors": delete_errors,
515
+ "message": f"去重完成:删除 {deleted_count} 个重复凭证,保留 {kept_count} 个凭证({duplicate_info.get('unique_email_count', 0)} 个唯一邮箱)",
516
+ }
517
+ )
518
+
519
+ except Exception as e:
520
+ log.error(f"批量去重凭证时出错: {e}")
521
+ return JSONResponse(
522
+ status_code=500,
523
+ content={
524
+ "deleted_count": 0,
525
+ "kept_count": 0,
526
+ "total_count": 0,
527
+ "message": f"去重操作失败: {str(e)}",
528
+ }
529
+ )
530
+
531
+
532
+ async def verify_credential_project_common(filename: str, mode: str = "geminicli") -> JSONResponse:
533
+ """验证并重新获取凭证的project id的通用函数"""
534
+ mode = validate_mode(mode)
535
+
536
+ # 验证文件名
537
+ if not filename.endswith(".json"):
538
+ raise HTTPException(status_code=400, detail="无效的文件名")
539
+
540
+
541
+ storage_adapter = await get_storage_adapter()
542
+
543
+ # 获取凭证数据
544
+ credential_data = await storage_adapter.get_credential(filename, mode=mode)
545
+ if not credential_data:
546
+ raise HTTPException(status_code=404, detail="凭证不存在")
547
+
548
+ # 创建凭证对象
549
+ credentials = Credentials.from_dict(credential_data)
550
+
551
+ # 确保token有效(自动刷新)
552
+ token_refreshed = await credentials.refresh_if_needed()
553
+
554
+ # 如果token被刷新了,更新存储
555
+ if token_refreshed:
556
+ log.info(f"Token已自动刷新: {filename} (mode={mode})")
557
+ credential_data = credentials.to_dict()
558
+ await storage_adapter.store_credential(filename, credential_data, mode=mode)
559
+
560
+ # 获取API端点和对应的User-Agent
561
+ if mode == "antigravity":
562
+ api_base_url = await get_antigravity_api_url()
563
+ user_agent = ANTIGRAVITY_USER_AGENT
564
+ else:
565
+ api_base_url = await get_code_assist_endpoint()
566
+ user_agent = GEMINICLI_USER_AGENT
567
+
568
+ # 重新获取project id(仅 antigravity 模式请求积分)
569
+ if mode == "antigravity":
570
+ project_id, subscription_tier, credit_amount = await fetch_project_id_and_tier(
571
+ access_token=credentials.access_token,
572
+ user_agent=user_agent,
573
+ api_base_url=api_base_url,
574
+ include_credits=True,
575
+ )
576
+ else:
577
+ project_id, subscription_tier = await fetch_project_id_and_tier(
578
+ access_token=credentials.access_token,
579
+ user_agent=user_agent,
580
+ api_base_url=api_base_url,
581
+ )
582
+ credit_amount = None
583
+
584
+ if project_id:
585
+ credential_data["project_id"] = project_id
586
+
587
+ if project_id or subscription_tier:
588
+ await storage_adapter.store_credential(filename, credential_data, mode=mode)
589
+
590
+ # 检验成功后自动解除禁用状态并清除错误码
591
+ state_update = {
592
+ "disabled": False,
593
+ "error_codes": []
594
+ }
595
+
596
+ # 同步更新状态表中的 tier 字段
597
+ state_update["tier"] = subscription_tier
598
+
599
+ # 如果是 geminicli 模式,直接设置 preview=True
600
+ if mode == "geminicli":
601
+ state_update["preview"] = True
602
+
603
+ await storage_adapter.update_credential_state(filename, state_update, mode=mode)
604
+
605
+ log.info(f"检验 {mode} 凭证成功: {filename} - Project ID: {project_id}, Tier: {subscription_tier} - 已解除禁用并清除错误码")
606
+
607
+ response_data = {
608
+ "success": True,
609
+ "filename": filename,
610
+ "project_id": project_id,
611
+ "subscription_tier": subscription_tier,
612
+ "message": "检验成功!Project ID已更新,已解除禁用状态并清除错误码,403错误应该已恢复"
613
+ }
614
+
615
+ if mode == "antigravity" and credit_amount is not None:
616
+ response_data["credit_amount"] = credit_amount
617
+
618
+ return JSONResponse(content=response_data)
619
+ else:
620
+ return JSONResponse(
621
+ status_code=400,
622
+ content={
623
+ "success": False,
624
+ "filename": filename,
625
+ "message": "检验失败:无法获取Project ID,请检查凭证是否有效"
626
+ }
627
+ )
628
+
629
+
630
+ # =============================================================================
631
+ # 路由处理函数 (Route Handlers)
632
+ # =============================================================================
633
+
634
+
635
+ @router.post("/upload")
636
+ async def upload_credentials(
637
+ files: List[UploadFile] = File(...),
638
+ token: str = Depends(verify_panel_token),
639
+ mode: str = "geminicli"
640
+ ):
641
+ """批量上传凭证文件"""
642
+ try:
643
+ mode = validate_mode(mode)
644
+ return await upload_credentials_common(files, mode=mode)
645
+ except HTTPException:
646
+ raise
647
+ except Exception as e:
648
+ log.error(f"批量上传失败: {e}")
649
+ raise HTTPException(status_code=500, detail=str(e))
650
+
651
+
652
+ @router.get("/status")
653
+ async def get_creds_status(
654
+ token: str = Depends(verify_panel_token),
655
+ offset: int = 0,
656
+ limit: int = 50,
657
+ status_filter: str = "all",
658
+ error_code_filter: str = "all",
659
+ cooldown_filter: str = "all",
660
+ preview_filter: str = "all",
661
+ tier_filter: str = "all",
662
+ mode: str = "geminicli"
663
+ ):
664
+ """
665
+ 获取凭证文件的状态(轻量级摘要,不包含完整凭证数据,支持分页和状态筛选)
666
+
667
+ Args:
668
+ offset: 跳过的记录数(默认0)
669
+ limit: 每页返回的记录数(默认50,可选:20, 50, 100, 200, 500, 1000)
670
+ status_filter: 状态筛选(all=全部, enabled=仅启用, disabled=仅禁用)
671
+ error_code_filter: 错误码筛选(all=全部, 或具体错误码如"400", "403")
672
+ cooldown_filter: 冷却状态筛选(all=全部, in_cooldown=冷却中, no_cooldown=未冷却)
673
+ preview_filter: Preview筛选(all=全部, preview=支持preview, no_preview=不支持preview,仅geminicli模式有效)
674
+ tier_filter: tier筛选(all=全部, free/pro/ultra)
675
+ mode: 凭证模式(geminicli 或 antigravity)
676
+
677
+ Returns:
678
+ 包含凭证列表、总数、分页信息的响应
679
+ """
680
+ try:
681
+ mode = validate_mode(mode)
682
+ return await get_creds_status_common(
683
+ offset, limit, status_filter, mode=mode,
684
+ error_code_filter=error_code_filter,
685
+ cooldown_filter=cooldown_filter,
686
+ preview_filter=preview_filter,
687
+ tier_filter=tier_filter
688
+ )
689
+ except HTTPException:
690
+ raise
691
+ except Exception as e:
692
+ log.error(f"获取凭证状态失败: {e}")
693
+ raise HTTPException(status_code=500, detail=str(e))
694
+
695
+
696
+ @router.get("/detail/{filename}")
697
+ async def get_cred_detail(
698
+ filename: str,
699
+ token: str = Depends(verify_panel_token),
700
+ mode: str = "geminicli"
701
+ ):
702
+ """
703
+ 按需获取单个凭证的详细数据(包含完整凭证内容)
704
+ 用于用户查看/编辑凭证详情
705
+ """
706
+ try:
707
+ mode = validate_mode(mode)
708
+ # 验证文件名
709
+ if not filename.endswith(".json"):
710
+ raise HTTPException(status_code=400, detail="无效的文件名")
711
+
712
+
713
+
714
+ storage_adapter = await get_storage_adapter()
715
+ backend_info = await storage_adapter.get_backend_info()
716
+ backend_type = backend_info.get("backend_type", "unknown")
717
+
718
+ # 获取凭证数据
719
+ credential_data = await storage_adapter.get_credential(filename, mode=mode)
720
+ if not credential_data:
721
+ raise HTTPException(status_code=404, detail="凭证不存在")
722
+
723
+ # 获取状态信息
724
+ file_status = await storage_adapter.get_credential_state(filename, mode=mode)
725
+ if not file_status:
726
+ file_status = {
727
+ "error_codes": [],
728
+ "disabled": False,
729
+ "last_success": time.time(),
730
+ "user_email": None,
731
+ }
732
+
733
+ result = {
734
+ "status": file_status,
735
+ "content": credential_data,
736
+ "filename": os.path.basename(filename),
737
+ "backend_type": backend_type,
738
+ "user_email": file_status.get("user_email"),
739
+ "model_cooldowns": file_status.get("model_cooldowns", {}),
740
+ }
741
+
742
+ if mode == "geminicli":
743
+ result["preview"] = file_status.get("preview", True)
744
+ else:
745
+ result["enable_credit"] = file_status.get("enable_credit", False)
746
+
747
+ if backend_type == "file" and os.path.exists(filename):
748
+ result.update({
749
+ "size": os.path.getsize(filename),
750
+ "modified_time": os.path.getmtime(filename),
751
+ })
752
+
753
+ return JSONResponse(content=result)
754
+
755
+ except HTTPException:
756
+ raise
757
+ except Exception as e:
758
+ log.error(f"获取凭证详情失败 {filename}: {e}")
759
+ raise HTTPException(status_code=500, detail=str(e))
760
+
761
+
762
+ @router.post("/action")
763
+ async def creds_action(
764
+ request: CredFileActionRequest,
765
+ token: str = Depends(verify_panel_token),
766
+ mode: str = "geminicli"
767
+ ):
768
+ """对凭证文件执行操作(启用/禁用/删除/enable_credit开关)"""
769
+ try:
770
+ mode = validate_mode(mode)
771
+
772
+ log.info(f"Received request: {request}")
773
+
774
+ filename = request.filename
775
+ action = request.action
776
+
777
+ log.info(f"Performing action '{action}' on file: {filename} (mode={mode})")
778
+
779
+ # 验证文件名
780
+ if not filename.endswith(".json"):
781
+ log.error(f"无效的文件名: {filename}(不是.json文件)")
782
+ raise HTTPException(status_code=400, detail=f"无效的文件名: {filename}")
783
+
784
+ # 获取存储适配器
785
+ storage_adapter = await get_storage_adapter()
786
+
787
+ # 对于删除操作,不需要检查凭证数据是否完整,只需检查条目是否存在
788
+ # 对于其他操作,需要确保凭证数据存在且完整
789
+ if action != "delete":
790
+ # 检查凭证数据是否存在
791
+ credential_data = await storage_adapter.get_credential(filename, mode=mode)
792
+ if not credential_data:
793
+ log.error(f"凭证未找到: {filename} (mode={mode})")
794
+ raise HTTPException(status_code=404, detail="凭证文件不存在")
795
+
796
+ if action == "enable":
797
+ log.info(f"Web请求: 启用文件 {filename} (mode={mode})")
798
+ result = await credential_manager.set_cred_disabled(filename, False, mode=mode)
799
+ log.info(f"[WebRoute] set_cred_disabled 返回结果: {result}")
800
+ if result:
801
+ log.info(f"Web请求: 文件 {filename} 已成功启用 (mode={mode})")
802
+ return JSONResponse(content={"message": f"已启用凭证文件 {os.path.basename(filename)}"})
803
+ else:
804
+ log.error(f"Web请求: 文件 {filename} 启用失败 (mode={mode})")
805
+ raise HTTPException(status_code=500, detail="启用凭证失败,可能凭证不存在")
806
+
807
+ elif action == "disable":
808
+ log.info(f"Web请求: 禁用文件 {filename} (mode={mode})")
809
+ result = await credential_manager.set_cred_disabled(filename, True, mode=mode)
810
+ log.info(f"[WebRoute] set_cred_disabled 返回结果: {result}")
811
+ if result:
812
+ log.info(f"Web请求: 文件 {filename} 已成功禁用 (mode={mode})")
813
+ return JSONResponse(content={"message": f"已禁用凭证文件 {os.path.basename(filename)}"})
814
+ else:
815
+ log.error(f"Web请求: 文件 {filename} 禁用失败 (mode={mode})")
816
+ raise HTTPException(status_code=500, detail="禁用凭证失败,可能凭证不存在")
817
+
818
+ elif action == "delete":
819
+ try:
820
+ # 使用 CredentialManager 删除凭证(包含队列/状态同步)
821
+ success = await credential_manager.remove_credential(filename, mode=mode)
822
+ if success:
823
+ log.info(f"通过管理器成功删除凭证: {filename} (mode={mode})")
824
+ return JSONResponse(
825
+ content={"message": f"已删除凭证文件 {os.path.basename(filename)}"}
826
+ )
827
+ else:
828
+ raise HTTPException(status_code=500, detail="删除凭证失败")
829
+ except Exception as e:
830
+ log.error(f"删除凭证 {filename} 时出错: {e}")
831
+ raise HTTPException(status_code=500, detail=f"删除文件失败: {str(e)}")
832
+
833
+ elif action == "enable_credit":
834
+ if mode != "antigravity":
835
+ raise HTTPException(status_code=400, detail="enable_credit 仅支持 antigravity 模式")
836
+ updated = await storage_adapter.update_credential_state(
837
+ filename, {"enable_credit": True}, mode=mode
838
+ )
839
+ if updated:
840
+ await clear_all_model_cooldowns_for_credential(storage_adapter, filename, mode)
841
+ return JSONResponse(content={"message": f"已开启凭证信用额度模式 {os.path.basename(filename)}"})
842
+ raise HTTPException(status_code=500, detail="开启信用额度模式失败,可能凭证不存在")
843
+
844
+ elif action == "disable_credit":
845
+ if mode != "antigravity":
846
+ raise HTTPException(status_code=400, detail="disable_credit 仅支持 antigravity 模式")
847
+ updated = await storage_adapter.update_credential_state(
848
+ filename, {"enable_credit": False}, mode=mode
849
+ )
850
+ if updated:
851
+ await clear_all_model_cooldowns_for_credential(storage_adapter, filename, mode)
852
+ return JSONResponse(content={"message": f"已关闭凭证信用额度模式 {os.path.basename(filename)}"})
853
+ raise HTTPException(status_code=500, detail="关闭信用额度模式失败,可能凭证不存在")
854
+
855
+ else:
856
+ raise HTTPException(status_code=400, detail="无效的操作类型")
857
+
858
+ except HTTPException:
859
+ raise
860
+ except Exception as e:
861
+ log.error(f"凭证文件操作失败: {e}")
862
+ raise HTTPException(status_code=500, detail=str(e))
863
+
864
+
865
+ @router.post("/batch-action")
866
+ async def creds_batch_action(
867
+ request: CredFileBatchActionRequest,
868
+ token: str = Depends(verify_panel_token),
869
+ mode: str = "geminicli"
870
+ ):
871
+ """批量对凭证文件执行操作(启用/禁用/删除/enable_credit开关)"""
872
+ try:
873
+ mode = validate_mode(mode)
874
+
875
+ action = request.action
876
+ filenames = request.filenames
877
+
878
+ if not filenames:
879
+ raise HTTPException(status_code=400, detail="文件名列表不能为空")
880
+
881
+ log.info(f"对 {len(filenames)} 个文件执行批量操作 '{action}'")
882
+
883
+ success_count = 0
884
+ errors = []
885
+
886
+ storage_adapter = await get_storage_adapter()
887
+
888
+ for filename in filenames:
889
+ try:
890
+ # 验证文件名安全性
891
+ if not filename.endswith(".json"):
892
+ errors.append(f"{filename}: 无效的文件类型")
893
+ continue
894
+
895
+ # ���于删除操作,不需要检查凭证数据完整性
896
+ # 对于其他操作,需要确保凭证数据存在
897
+ if action != "delete":
898
+ credential_data = await storage_adapter.get_credential(filename, mode=mode)
899
+ if not credential_data:
900
+ errors.append(f"{filename}: 凭证不存在")
901
+ continue
902
+
903
+ # 执行相应操作
904
+ if action == "enable":
905
+ await credential_manager.set_cred_disabled(filename, False, mode=mode)
906
+ success_count += 1
907
+
908
+ elif action == "disable":
909
+ await credential_manager.set_cred_disabled(filename, True, mode=mode)
910
+ success_count += 1
911
+
912
+ elif action == "delete":
913
+ try:
914
+ delete_success = await credential_manager.remove_credential(filename, mode=mode)
915
+ if delete_success:
916
+ success_count += 1
917
+ log.info(f"成功删除批量中的凭证: {filename}")
918
+ else:
919
+ errors.append(f"{filename}: 删除失败")
920
+ continue
921
+ except Exception as e:
922
+ errors.append(f"{filename}: 删除文件失败 - {str(e)}")
923
+ continue
924
+ elif action == "enable_credit":
925
+ if mode != "antigravity":
926
+ errors.append(f"{filename}: enable_credit 仅支持 antigravity 模式")
927
+ continue
928
+ updated = await storage_adapter.update_credential_state(
929
+ filename, {"enable_credit": True}, mode=mode
930
+ )
931
+ if updated:
932
+ await clear_all_model_cooldowns_for_credential(storage_adapter, filename, mode)
933
+ success_count += 1
934
+ else:
935
+ errors.append(f"{filename}: 开启信用额度模式失败")
936
+ continue
937
+ elif action == "disable_credit":
938
+ if mode != "antigravity":
939
+ errors.append(f"{filename}: disable_credit 仅支持 antigravity 模式")
940
+ continue
941
+ updated = await storage_adapter.update_credential_state(
942
+ filename, {"enable_credit": False}, mode=mode
943
+ )
944
+ if updated:
945
+ await clear_all_model_cooldowns_for_credential(storage_adapter, filename, mode)
946
+ success_count += 1
947
+ else:
948
+ errors.append(f"{filename}: 关闭信用额度模式失败")
949
+ continue
950
+ else:
951
+ errors.append(f"{filename}: 无效的操作类型")
952
+ continue
953
+
954
+ except Exception as e:
955
+ log.error(f"处理 {filename} 时出错: {e}")
956
+ errors.append(f"{filename}: 处理失败 - {str(e)}")
957
+ continue
958
+
959
+ # 构建返回消息
960
+ result_message = f"批量操作完成:成功处理 {success_count}/{len(filenames)} 个文件"
961
+ if errors:
962
+ result_message += "\n错误详情:\n" + "\n".join(errors)
963
+
964
+ response_data = {
965
+ "success_count": success_count,
966
+ "total_count": len(filenames),
967
+ "errors": errors,
968
+ "message": result_message,
969
+ }
970
+
971
+ return JSONResponse(content=response_data)
972
+
973
+ except HTTPException:
974
+ raise
975
+ except Exception as e:
976
+ log.error(f"批量凭证文件操作失败: {e}")
977
+ raise HTTPException(status_code=500, detail=str(e))
978
+
979
+
980
+ @router.get("/download/{filename}")
981
+ async def download_cred_file(
982
+ filename: str,
983
+ token: str = Depends(verify_panel_token),
984
+ mode: str = "geminicli"
985
+ ):
986
+ """下载单个凭证文件"""
987
+ try:
988
+ mode = validate_mode(mode)
989
+ # 验证文件名安全性
990
+ if not filename.endswith(".json"):
991
+ raise HTTPException(status_code=404, detail="无效的文件名")
992
+
993
+ # 获取存储适配器
994
+ storage_adapter = await get_storage_adapter()
995
+
996
+ # 从存储系统获取凭证数据
997
+ credential_data = await storage_adapter.get_credential(filename, mode=mode)
998
+ if not credential_data:
999
+ raise HTTPException(status_code=404, detail="文件不存在")
1000
+
1001
+ # 转换为JSON字符串
1002
+ content = json.dumps(credential_data, ensure_ascii=False, indent=2)
1003
+
1004
+ from fastapi.responses import Response
1005
+
1006
+ return Response(
1007
+ content=content,
1008
+ media_type="application/json",
1009
+ headers={"Content-Disposition": f"attachment; filename={filename}"},
1010
+ )
1011
+
1012
+ except HTTPException:
1013
+ raise
1014
+ except Exception as e:
1015
+ log.error(f"下载凭证文件失败: {e}")
1016
+ raise HTTPException(status_code=500, detail=str(e))
1017
+
1018
+
1019
+ @router.post("/fetch-email/{filename}")
1020
+ async def fetch_user_email(
1021
+ filename: str,
1022
+ token: str = Depends(verify_panel_token),
1023
+ mode: str = "geminicli"
1024
+ ):
1025
+ """获取指定凭证文件的用户邮箱地址"""
1026
+ try:
1027
+ mode = validate_mode(mode)
1028
+ return await fetch_user_email_common(filename, mode=mode)
1029
+ except HTTPException:
1030
+ raise
1031
+ except Exception as e:
1032
+ log.error(f"获取用户邮箱失败: {e}")
1033
+ raise HTTPException(status_code=500, detail=str(e))
1034
+
1035
+
1036
+ @router.post("/refresh-all-emails")
1037
+ async def refresh_all_user_emails(
1038
+ token: str = Depends(verify_panel_token),
1039
+ mode: str = "geminicli"
1040
+ ):
1041
+ """刷新所有凭证文件的用户邮箱地址"""
1042
+ try:
1043
+ mode = validate_mode(mode)
1044
+ return await refresh_all_user_emails_common(mode=mode)
1045
+ except Exception as e:
1046
+ log.error(f"批量获取用户邮箱失败: {e}")
1047
+ raise HTTPException(status_code=500, detail=str(e))
1048
+
1049
+
1050
+ @router.post("/deduplicate-by-email")
1051
+ async def deduplicate_credentials_by_email(
1052
+ token: str = Depends(verify_panel_token),
1053
+ mode: str = "geminicli"
1054
+ ):
1055
+ """批量去重凭证文件 - 删除邮箱相同的凭证(只保留一个)"""
1056
+ try:
1057
+ mode = validate_mode(mode)
1058
+ return await deduplicate_credentials_by_email_common(mode=mode)
1059
+ except Exception as e:
1060
+ log.error(f"批量去重凭证失败: {e}")
1061
+ raise HTTPException(status_code=500, detail=str(e))
1062
+
1063
+
1064
+ @router.get("/download-all")
1065
+ async def download_all_creds(
1066
+ token: str = Depends(verify_panel_token),
1067
+ mode: str = "geminicli"
1068
+ ):
1069
+ """
1070
+ 打包下载所有凭证文件(流式处理,按需加载每个凭证数据)
1071
+ 只在实际下载时才加载完整凭证内容,最大化性能
1072
+ """
1073
+ try:
1074
+ mode = validate_mode(mode)
1075
+ return await download_all_creds_common(mode=mode)
1076
+ except HTTPException:
1077
+ raise
1078
+ except Exception as e:
1079
+ log.error(f"打包下载失败: {e}")
1080
+ raise HTTPException(status_code=500, detail=str(e))
1081
+
1082
+
1083
+ @router.post("/verify-project/{filename}")
1084
+ async def verify_credential_project(
1085
+ filename: str,
1086
+ token: str = Depends(verify_panel_token),
1087
+ mode: str = "geminicli"
1088
+ ):
1089
+ """
1090
+ 检验凭证的project id,重新获取project id
1091
+ 检验成功可以使403错误恢复
1092
+ """
1093
+ try:
1094
+ mode = validate_mode(mode)
1095
+ return await verify_credential_project_common(filename, mode=mode)
1096
+ except HTTPException:
1097
+ raise
1098
+ except Exception as e:
1099
+ log.error(f"检验凭证Project ID失败 {filename}: {e}")
1100
+ raise HTTPException(status_code=500, detail=f"检验失败: {str(e)}")
1101
+
1102
+
1103
+ @router.get("/errors/{filename}")
1104
+ async def get_credential_errors(
1105
+ filename: str,
1106
+ token: str = Depends(verify_panel_token),
1107
+ mode: str = "geminicli"
1108
+ ):
1109
+ """
1110
+ 获取指定凭证的错误信息(包含 error_codes 和 error_messages)
1111
+
1112
+ Args:
1113
+ filename: 凭证文件名
1114
+ mode: 凭证模式(geminicli 或 antigravity)
1115
+
1116
+ Returns:
1117
+ 包含 error_codes 和 error_messages 的 JSON 响应
1118
+ """
1119
+ try:
1120
+ mode = validate_mode(mode)
1121
+
1122
+ # 验证文件名
1123
+ if not filename.endswith(".json"):
1124
+ raise HTTPException(status_code=400, detail="无效的文件名")
1125
+
1126
+ storage_adapter = await get_storage_adapter()
1127
+
1128
+ # 检查后端是否支持 get_credential_errors 方法
1129
+ if not hasattr(storage_adapter._backend, 'get_credential_errors'):
1130
+ raise HTTPException(
1131
+ status_code=501,
1132
+ detail="当前存储后端不支持获取错误信息"
1133
+ )
1134
+
1135
+ # 获取错误信息
1136
+ error_info = await storage_adapter._backend.get_credential_errors(filename, mode=mode)
1137
+
1138
+ return JSONResponse(content=error_info)
1139
+
1140
+ except HTTPException:
1141
+ raise
1142
+ except Exception as e:
1143
+ log.error(f"获取凭证错误信息失败 {filename}: {e}")
1144
+ raise HTTPException(status_code=500, detail=str(e))
1145
+
1146
+
1147
+ @router.get("/quota/{filename}")
1148
+ async def get_credential_quota(
1149
+ filename: str,
1150
+ token: str = Depends(verify_panel_token),
1151
+ mode: str = "antigravity"
1152
+ ):
1153
+ """
1154
+ 获取指定凭证的额度信息(仅支持 antigravity 模式)
1155
+ """
1156
+ try:
1157
+ mode = validate_mode(mode)
1158
+ # 验证文件名
1159
+ if not filename.endswith(".json"):
1160
+ raise HTTPException(status_code=400, detail="无效的文件名")
1161
+
1162
+
1163
+ storage_adapter = await get_storage_adapter()
1164
+
1165
+ # 获取凭证数据
1166
+ credential_data = await storage_adapter.get_credential(filename, mode=mode)
1167
+ if not credential_data:
1168
+ raise HTTPException(status_code=404, detail="凭证不存在")
1169
+
1170
+ # 使用 Credentials 对象自动处理 token 刷新
1171
+ from src.google_oauth_api import Credentials
1172
+
1173
+ creds = Credentials.from_dict(credential_data)
1174
+
1175
+ # 自动刷新 token(如果需要)
1176
+ await creds.refresh_if_needed()
1177
+
1178
+ # 如果 token 被刷新了,更新存储
1179
+ updated_data = creds.to_dict()
1180
+ if updated_data != credential_data:
1181
+ log.info(f"Token已自动刷新: {filename}")
1182
+ await storage_adapter.store_credential(filename, updated_data, mode=mode)
1183
+ credential_data = updated_data
1184
+
1185
+ # 获取访问令牌
1186
+ access_token = credential_data.get("access_token") or credential_data.get("token")
1187
+ if not access_token:
1188
+ raise HTTPException(status_code=400, detail="凭证中没有访问令牌")
1189
+
1190
+ # 获取额度信息
1191
+ quota_info = await fetch_quota_info(access_token)
1192
+
1193
+ if quota_info.get("success"):
1194
+ return JSONResponse(content={
1195
+ "success": True,
1196
+ "filename": filename,
1197
+ "models": quota_info.get("models", {})
1198
+ })
1199
+ else:
1200
+ return JSONResponse(
1201
+ status_code=400,
1202
+ content={
1203
+ "success": False,
1204
+ "filename": filename,
1205
+ "error": quota_info.get("error", "未知错误")
1206
+ }
1207
+ )
1208
+
1209
+ except HTTPException:
1210
+ raise
1211
+ except Exception as e:
1212
+ log.error(f"获取凭证额度失败 {filename}: {e}")
1213
+ raise HTTPException(status_code=500, detail=f"获取额度失败: {str(e)}")
1214
+
1215
+
1216
+ @router.post("/configure-preview/{filename}")
1217
+ async def configure_preview_channel(
1218
+ filename: str,
1219
+ token: str = Depends(verify_panel_token),
1220
+ mode: str = "geminicli"
1221
+ ):
1222
+ """
1223
+ 为 geminicli 凭证配置 preview 通道
1224
+
1225
+ 通过调用 Google Cloud API 设置 release_channel 为 EXPERIMENTAL
1226
+
1227
+ Args:
1228
+ filename: 凭证文件名
1229
+ mode: 凭证模式(仅支持 geminicli)
1230
+
1231
+ Returns:
1232
+ 配置结果信息
1233
+ """
1234
+ try:
1235
+ mode = validate_mode(mode)
1236
+
1237
+ # 只支持 geminicli 模式
1238
+ if mode != "geminicli":
1239
+ raise HTTPException(
1240
+ status_code=400,
1241
+ detail="配置 preview 通道仅支持 geminicli 模式"
1242
+ )
1243
+
1244
+ # 验证文件名
1245
+ if not filename.endswith(".json"):
1246
+ raise HTTPException(status_code=400, detail="无效的文件名")
1247
+
1248
+ storage_adapter = await get_storage_adapter()
1249
+
1250
+ # 获取凭证数据
1251
+ credential_data = await storage_adapter.get_credential(filename, mode=mode)
1252
+ if not credential_data:
1253
+ raise HTTPException(status_code=404, detail="凭证不存在")
1254
+
1255
+ # 创建凭证对象并刷新 token(如果需要)
1256
+ credentials = Credentials.from_dict(credential_data)
1257
+ token_refreshed = await credentials.refresh_if_needed()
1258
+
1259
+ if token_refreshed:
1260
+ log.info(f"Token已自动刷新: {filename}")
1261
+ credential_data = credentials.to_dict()
1262
+ await storage_adapter.store_credential(filename, credential_data, mode=mode)
1263
+
1264
+ # 获取 access_token 和 project_id
1265
+ access_token = credential_data.get("access_token") or credential_data.get("token")
1266
+ project_id = credential_data.get("project_id", "")
1267
+
1268
+ if not access_token:
1269
+ raise HTTPException(status_code=400, detail="凭证中没有访问令牌")
1270
+ if not project_id:
1271
+ raise HTTPException(status_code=400, detail="凭证中没有项目ID")
1272
+
1273
+ # 调用 Google Cloud API 配置 preview 通道
1274
+ # 根据文档,需要两个步骤:
1275
+ # 1. 创建 Release Channel Setting (EXPERIMENTAL)
1276
+ # 2. 创建 Setting Binding (绑定到目标项目)
1277
+ from src.httpx_client import post_async
1278
+ import uuid
1279
+
1280
+ # 生成唯一的 ID
1281
+ setting_id = f"preview-setting-{uuid.uuid4().hex[:8]}"
1282
+ binding_id = f"preview-binding-{uuid.uuid4().hex[:8]}"
1283
+
1284
+ base_url = f"https://cloudaicompanion.googleapis.com/v1/projects/{project_id}/locations/global"
1285
+ headers = {
1286
+ "Authorization": f"Bearer {access_token}",
1287
+ "Content-Type": "application/json"
1288
+ }
1289
+
1290
+ log.info(f"开始配置 preview 通道: {filename} (project_id={project_id})")
1291
+
1292
+ # 步骤 1: 创建 Release Channel Setting
1293
+ setting_url = f"{base_url}/releaseChannelSettings"
1294
+ setting_response = await post_async(
1295
+ url=setting_url,
1296
+ json={"release_channel": "EXPERIMENTAL"},
1297
+ headers=headers,
1298
+ params={"release_channel_setting_id": setting_id},
1299
+ timeout=30.0
1300
+ )
1301
+
1302
+ setting_status = setting_response.status_code
1303
+
1304
+ if setting_status == 200 or setting_status == 201:
1305
+ log.info(f"步骤 1/2: Release Channel Setting 创建成功 (setting_id={setting_id})")
1306
+ elif setting_status == 409:
1307
+ # Setting 已存在,继续下一步
1308
+ log.info(f"步骤 1/2: Release Channel Setting 已存在")
1309
+ else:
1310
+ # 步骤 1 失败
1311
+ error_text = setting_response.text if hasattr(setting_response, 'text') else ""
1312
+ log.error(f"步骤 1/2 失败: {filename} - Status: {setting_status}, Error: {error_text}")
1313
+
1314
+ return JSONResponse(
1315
+ status_code=setting_status,
1316
+ content={
1317
+ "success": False,
1318
+ "filename": filename,
1319
+ "preview": False,
1320
+ "message": f"创建 Release Channel Setting 失败: HTTP {setting_status}",
1321
+ "error": error_text,
1322
+ "step": "create_setting"
1323
+ }
1324
+ )
1325
+
1326
+ # 步骤 2: 创建 Setting Binding (绑定到当前项目)
1327
+ binding_url = f"{base_url}/releaseChannelSettings/{setting_id}/settingBindings"
1328
+ binding_response = await post_async(
1329
+ url=binding_url,
1330
+ json={
1331
+ "target": f"projects/{project_id}",
1332
+ "product": "GEMINI_CODE_ASSIST"
1333
+ },
1334
+ headers=headers,
1335
+ params={"setting_binding_id": binding_id},
1336
+ timeout=30.0
1337
+ )
1338
+
1339
+ binding_status = binding_response.status_code
1340
+
1341
+ if binding_status == 200 or binding_status == 201:
1342
+ await storage_adapter.update_credential_state(filename, {
1343
+ "preview": True
1344
+ }, mode=mode)
1345
+
1346
+ log.info(f"步骤 2/2: Setting Binding 创建成功 - Preview 通道配置完成: {filename}")
1347
+
1348
+ return JSONResponse(content={
1349
+ "success": True,
1350
+ "filename": filename,
1351
+ "preview": True,
1352
+ "message": "Preview 通道配置成功,已将 preview 属性设置为 true",
1353
+ "setting_id": setting_id,
1354
+ "binding_id": binding_id
1355
+ })
1356
+ elif binding_status == 409:
1357
+ # Binding 已存在,说明已经配置过了
1358
+ await storage_adapter.update_credential_state(filename, {
1359
+ "preview": True
1360
+ }, mode=mode)
1361
+
1362
+ log.info(f"步骤 2/2: Setting Binding 已存在 - Preview 通道已配置: {filename}")
1363
+
1364
+ return JSONResponse(content={
1365
+ "success": True,
1366
+ "filename": filename,
1367
+ "preview": True,
1368
+ "message": "Preview 通道配置已存在,已将 preview 属性设置为 true"
1369
+ })
1370
+ else:
1371
+ # 步骤 2 失败
1372
+ error_text = binding_response.text if hasattr(binding_response, 'text') else ""
1373
+ log.error(f"步骤 2/2 失败: {filename} - Status: {binding_status}, Error: {error_text}")
1374
+
1375
+ return JSONResponse(
1376
+ status_code=binding_status,
1377
+ content={
1378
+ "success": False,
1379
+ "filename": filename,
1380
+ "preview": False,
1381
+ "message": f"创建 Setting Binding 失败: HTTP {binding_status}",
1382
+ "error": error_text,
1383
+ "step": "create_binding"
1384
+ }
1385
+ )
1386
+
1387
+ except HTTPException:
1388
+ raise
1389
+ except Exception as e:
1390
+ log.error(f"配置 preview 通道失败 {filename}: {e}")
1391
+ raise HTTPException(status_code=500, detail=f"配置失败: {str(e)}")
1392
+
1393
+
1394
+ @router.post("/test/{filename}")
1395
+ async def test_credential(
1396
+ filename: str,
1397
+ mode: str = "geminicli",
1398
+ _token: str = Depends(verify_panel_token)
1399
+ ):
1400
+ """
1401
+ 测试指定凭证是否可用
1402
+
1403
+ Args:
1404
+ filename: 凭证文件名
1405
+ mode: 凭证模式(geminicli 或 antigravity)
1406
+
1407
+ Returns:
1408
+ 返回状态码:
1409
+ - 200: 凭证可用
1410
+ - 429: 凭证被限流但有效
1411
+ - 其他: 凭证失败(返回实际错误码)
1412
+ """
1413
+ try:
1414
+ mode = validate_mode(mode)
1415
+
1416
+ # 验证文件名
1417
+ if not filename.endswith(".json"):
1418
+ raise HTTPException(status_code=400, detail="无效的文件名")
1419
+
1420
+ storage_adapter = await get_storage_adapter()
1421
+
1422
+ # 获取凭证数据
1423
+ credential_data = await storage_adapter.get_credential(filename, mode=mode)
1424
+ if not credential_data:
1425
+ raise HTTPException(status_code=404, detail="凭证不存在")
1426
+
1427
+ # 创建凭证对象并尝试刷新 token(如果需要)
1428
+ credentials = Credentials.from_dict(credential_data)
1429
+ token_refreshed = await credentials.refresh_if_needed()
1430
+
1431
+ # 如果 token 被刷新了,更新存储
1432
+ if token_refreshed:
1433
+ log.info(f"Token已自动刷新: {filename} (mode={mode})")
1434
+ credential_data = credentials.to_dict()
1435
+ await storage_adapter.store_credential(filename, credential_data, mode=mode)
1436
+
1437
+ # 获取访问令牌
1438
+ access_token = credential_data.get("access_token") or credential_data.get("token")
1439
+ if not access_token:
1440
+ raise HTTPException(status_code=400, detail="凭证中没有访问令牌")
1441
+
1442
+ # 根据模式构造测试请求
1443
+ from src.httpx_client import post_async
1444
+
1445
+ # 获取 project_id
1446
+ project_id = credential_data.get("project_id", "")
1447
+ if not project_id:
1448
+ raise HTTPException(status_code=400, detail="凭证中没有项目ID")
1449
+
1450
+ # 根据模式选择 API 端点和请求头
1451
+ # 对于 geminicli 模式,使用两次测试:gemini-2.5-flash 和 gemini-3-flash-preview
1452
+ # 对于 antigravity 模式,只使用 gemini-2.5-flash
1453
+ test_model = "gemini-2.5-flash"
1454
+
1455
+ if mode == "antigravity":
1456
+ api_base_url = await get_antigravity_api_url()
1457
+ from src.api.antigravity import build_antigravity_headers
1458
+ headers = build_antigravity_headers(access_token, test_model)
1459
+ else:
1460
+ api_base_url = await get_code_assist_endpoint()
1461
+ headers = {
1462
+ "Authorization": f"Bearer {access_token}",
1463
+ "Content-Type": "application/json",
1464
+ "User-Agent": GEMINICLI_USER_AGENT,
1465
+ }
1466
+
1467
+ # 第一次测试:使用 gemini-2.5-flash
1468
+ response = await post_async(
1469
+ url=f"{api_base_url}/v1internal:generateContent",
1470
+ json={
1471
+ "model": test_model,
1472
+ "project": project_id,
1473
+ "request": {
1474
+ "contents": [{"role": "user", "parts": [{"text": "hi"}]}],
1475
+ "generationConfig": {"maxOutputTokens": 1}
1476
+ }
1477
+ },
1478
+ headers=headers,
1479
+ timeout=30.0
1480
+ )
1481
+
1482
+ # 返回实际的状态码和详细信息
1483
+ status_code = response.status_code
1484
+
1485
+ if status_code == 200 or status_code == 429:
1486
+ log.info(f"凭证测试成功: {filename} (mode={mode}, model={test_model}, status={status_code})")
1487
+ # 测试成功时清除错误状态
1488
+ if status_code == 200:
1489
+ await storage_adapter.update_credential_state(filename, {
1490
+ "error_codes": [],
1491
+ "error_messages": {}
1492
+ }, mode=mode)
1493
+
1494
+ # 如果是 geminicli 模式且第一次测试成功,继续测试 gemini-3-flash-preview
1495
+ if mode == "geminicli":
1496
+ preview_model = "gemini-3-flash-preview"
1497
+ log.info(f"开始测试 preview 模型: {filename} (model={preview_model})")
1498
+
1499
+ try:
1500
+ preview_response = await post_async(
1501
+ url=f"{api_base_url}/v1internal:generateContent",
1502
+ json={
1503
+ "model": preview_model,
1504
+ "project": project_id,
1505
+ "request": {
1506
+ "contents": [{"role": "user", "parts": [{"text": "hi"}]}],
1507
+ "generationConfig": {"maxOutputTokens": 1}
1508
+ }
1509
+ },
1510
+ headers=headers,
1511
+ timeout=30.0
1512
+ )
1513
+
1514
+ preview_status = preview_response.status_code
1515
+
1516
+ if preview_status == 200 or preview_status == 429:
1517
+ # preview 模型测试成功,设置 preview=True
1518
+ log.info(f"Preview 模型测试成功: {filename} (status={preview_status})")
1519
+ await storage_adapter.update_credential_state(filename, {
1520
+ "preview": True
1521
+ }, mode=mode)
1522
+ elif preview_status == 404:
1523
+ # preview 模型返回 404,说明不支持,设置 preview=False
1524
+ log.warning(f"Preview 模型不支持: {filename} (status=404)")
1525
+ await storage_adapter.update_credential_state(filename, {
1526
+ "preview": False
1527
+ }, mode=mode)
1528
+ else:
1529
+ # 其他错误,保持默认 preview 状态
1530
+ log.warning(f"Preview 模型测试失败: {filename} (status={preview_status})")
1531
+ except Exception as e:
1532
+ log.error(f"Preview 模型测试异常: {filename} - {e}")
1533
+
1534
+ # 返回成功响应
1535
+ return JSONResponse(
1536
+ status_code=status_code,
1537
+ content={
1538
+ "success": True,
1539
+ "status_code": status_code,
1540
+ "message": "测试成功",
1541
+ "filename": filename
1542
+ }
1543
+ )
1544
+ else:
1545
+ log.warning(f"凭证测试失败: {filename} (mode={mode}, status={status_code})")
1546
+ # 测试失败时保存错误码和错误消息(覆盖模式,只保存最新的一个错误)
1547
+ try:
1548
+ error_text = response.text if hasattr(response, 'text') else ""
1549
+
1550
+ # 打印详细错误内容到日志
1551
+ log.error(f"凭证测试错误详情 - 文件: {filename}, 模式: {mode}, 状态码: {status_code}, 错误内容: {error_text}")
1552
+
1553
+ # 使用覆盖模式保存错误(与 credential_manager 保持一致)
1554
+ error_codes = [status_code]
1555
+ error_messages = {str(status_code): error_text if error_text else f"HTTP {status_code}"}
1556
+
1557
+ # 更新状态
1558
+ await storage_adapter.update_credential_state(filename, {
1559
+ "error_codes": error_codes,
1560
+ "error_messages": error_messages
1561
+ }, mode=mode)
1562
+
1563
+ log.info(f"已保存测试错误信息: {filename} - 错误码 {status_code}")
1564
+ except Exception as e:
1565
+ log.error(f"保存测试错误信息失败: {e}")
1566
+
1567
+ # 返回错误响应,包含完整的错误信息
1568
+ error_text = response.text if hasattr(response, 'text') else ""
1569
+
1570
+ return JSONResponse(
1571
+ status_code=status_code,
1572
+ content={
1573
+ "success": False,
1574
+ "status_code": status_code,
1575
+ "message": f"测试失败: HTTP {status_code}",
1576
+ "error": error_text,
1577
+ "filename": filename
1578
+ }
1579
+ )
1580
+
1581
+ except HTTPException:
1582
+ raise
1583
+ except Exception as e:
1584
+ log.error(f"测试凭证失败 {filename}: {e}")
1585
+ raise HTTPException(status_code=500, detail=f"测试失败: {str(e)}")
src/panel/logs.py ADDED
@@ -0,0 +1,237 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 日志路由模块 - 处理 /logs/* 相关的HTTP请求和WebSocket连接
3
+ """
4
+
5
+ import asyncio
6
+ import datetime
7
+ import os
8
+
9
+ from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect
10
+ from fastapi.responses import FileResponse, JSONResponse
11
+ from starlette.websockets import WebSocketState
12
+
13
+ import config
14
+ from log import log
15
+ from src.utils import verify_panel_token
16
+ from .utils import ConnectionManager
17
+
18
+
19
+ # 创建路由器
20
+ router = APIRouter(prefix="/logs", tags=["logs"])
21
+
22
+ # WebSocket连接管理器
23
+ manager = ConnectionManager()
24
+
25
+
26
+ @router.post("/clear")
27
+ async def clear_logs(token: str = Depends(verify_panel_token)):
28
+ """清空日志文件"""
29
+ try:
30
+ # 直接使用环境变量获取日志文件路径
31
+ log_file_path = os.getenv("LOG_FILE", "log.txt")
32
+
33
+ # 检查日志文件是否存在
34
+ if os.path.exists(log_file_path):
35
+ try:
36
+ # 清空文件内容(保留文件),确保以UTF-8编码写入
37
+ # 使用 with 确保文件正确关闭
38
+ with open(log_file_path, "w", encoding="utf-8") as f:
39
+ f.write("")
40
+ f.flush() # 强制刷新到磁盘
41
+ # with 退出时会自动关闭文件
42
+ log.info(f"日志文件已清空: {log_file_path}")
43
+
44
+ # 通知所有WebSocket连接日志已清空
45
+ await manager.broadcast("--- 日志文件已清空 ---")
46
+
47
+ return JSONResponse(
48
+ content={"message": f"日志文件已清空: {os.path.basename(log_file_path)}"}
49
+ )
50
+ except Exception as e:
51
+ log.error(f"清空日志文件失败: {e}")
52
+ raise HTTPException(status_code=500, detail=f"清空日志文件失败: {str(e)}")
53
+ else:
54
+ return JSONResponse(content={"message": "日志文件不存在"})
55
+
56
+ except Exception as e:
57
+ log.error(f"清空日志文件失败: {e}")
58
+ raise HTTPException(status_code=500, detail=f"清空日志文件失败: {str(e)}")
59
+
60
+
61
+ @router.get("/download")
62
+ async def download_logs(token: str = Depends(verify_panel_token)):
63
+ """下载日志文件"""
64
+ try:
65
+ # 直接使用环境变量获取日志文件路径
66
+ log_file_path = os.getenv("LOG_FILE", "log.txt")
67
+
68
+ # 检查日志文件是否存在
69
+ if not os.path.exists(log_file_path):
70
+ raise HTTPException(status_code=404, detail="日志文件不存在")
71
+
72
+ # 检查文件是否为空
73
+ file_size = os.path.getsize(log_file_path)
74
+ if file_size == 0:
75
+ raise HTTPException(status_code=404, detail="日志文件为空")
76
+
77
+ # 生成文件名(包含时间戳)
78
+ timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
79
+ filename = f"gcli2api_logs_{timestamp}.txt"
80
+
81
+ log.info(f"下载日志文件: {log_file_path}")
82
+
83
+ return FileResponse(
84
+ path=log_file_path,
85
+ filename=filename,
86
+ media_type="text/plain",
87
+ headers={"Content-Disposition": f"attachment; filename={filename}"},
88
+ )
89
+
90
+ except HTTPException:
91
+ raise
92
+ except Exception as e:
93
+ log.error(f"下载日志文件失败: {e}")
94
+ raise HTTPException(status_code=500, detail=f"下载日志文件失败: {str(e)}")
95
+
96
+
97
+ @router.websocket("/stream")
98
+ async def websocket_logs(websocket: WebSocket):
99
+ """WebSocket端点,用于实时日志流"""
100
+ # WebSocket 认证: 从查询参数获取 token
101
+ token = websocket.query_params.get("token")
102
+
103
+ if not token:
104
+ await websocket.close(code=403, reason="Missing authentication token")
105
+ log.warning("WebSocket连接被拒绝: 缺少认证token")
106
+ return
107
+
108
+ # 验证 token
109
+ try:
110
+ panel_password = await config.get_panel_password()
111
+ if token != panel_password:
112
+ await websocket.close(code=403, reason="Invalid authentication token")
113
+ log.warning("WebSocket连接被拒绝: token验证失败")
114
+ return
115
+ except Exception as e:
116
+ await websocket.close(code=1011, reason="Authentication error")
117
+ log.error(f"WebSocket认证过程出错: {e}")
118
+ return
119
+
120
+ # 检查连接数限制
121
+ if not await manager.connect(websocket):
122
+ return
123
+
124
+ try:
125
+ # 直接使用环境变量获取日志文件路径
126
+ log_file_path = os.getenv("LOG_FILE", "log.txt")
127
+
128
+ # 发送初始日志(限制为最后50行,减少内存占用)
129
+ if os.path.exists(log_file_path):
130
+ try:
131
+ # 使用 with 确保文件正确关闭
132
+ with open(log_file_path, "r", encoding="utf-8") as f:
133
+ lines = f.readlines()
134
+ # 只发送最后50行,减少初始内存消耗
135
+ for line in lines[-50:]:
136
+ if line.strip():
137
+ await websocket.send_text(line.strip())
138
+ except Exception as e:
139
+ await websocket.send_text(f"Error reading log file: {e}")
140
+ log.error(f"WebSocket初始日志读取错误: {e}")
141
+
142
+ # 监控日志文件变化
143
+ last_size = os.path.getsize(log_file_path) if os.path.exists(log_file_path) else 0
144
+ max_read_size = 8192 # 限制单次读取大小为8KB,防止大量日志造成内存激增
145
+ check_interval = 2 # 增加检查间隔,减少CPU和I/O开销
146
+
147
+ # 创建后台任务监听客户端断开
148
+ # 即使没有日志更新,receive_text() 也能即时感知断开
149
+ async def listen_for_disconnect():
150
+ try:
151
+ while True:
152
+ await websocket.receive_text()
153
+ except Exception:
154
+ pass
155
+
156
+ listener_task = asyncio.create_task(listen_for_disconnect())
157
+
158
+ try:
159
+ while websocket.client_state == WebSocketState.CONNECTED:
160
+ # 使用 asyncio.wait 同时等待定时器和断开信号
161
+ # timeout=check_interval 替代了 asyncio.sleep
162
+ done, pending = await asyncio.wait(
163
+ [listener_task],
164
+ timeout=check_interval,
165
+ return_when=asyncio.FIRST_COMPLETED
166
+ )
167
+
168
+ # 如果监听任务结束(通常是因为连接断开),则退出循环
169
+ if listener_task in done:
170
+ break
171
+
172
+ if os.path.exists(log_file_path):
173
+ current_size = os.path.getsize(log_file_path)
174
+ if current_size > last_size:
175
+ # 限制读取大小,防止单次读取过多内容
176
+ read_size = min(current_size - last_size, max_read_size)
177
+
178
+ try:
179
+ # 使用 with 确保文件正确关闭,即使发生异常
180
+ with open(log_file_path, "r", encoding="utf-8", errors="replace") as f:
181
+ f.seek(last_size)
182
+ new_content = f.read(read_size)
183
+ # with 退出时自动关闭文件句柄
184
+
185
+ # 处理编码错误的情况
186
+ if not new_content:
187
+ last_size = current_size
188
+ continue
189
+
190
+ # 分行发送,避免发送不完整的行
191
+ lines = new_content.splitlines(keepends=True)
192
+ if lines:
193
+ # 如果最后一行没有换行符,保留到下次处理
194
+ if not lines[-1].endswith("\n") and len(lines) > 1:
195
+ # 除了最后一行,其他都发送
196
+ for line in lines[:-1]:
197
+ if line.strip():
198
+ await websocket.send_text(line.rstrip())
199
+ # 更新位置,但要退回最后一行的字节数
200
+ last_size += len(new_content.encode("utf-8")) - len(
201
+ lines[-1].encode("utf-8")
202
+ )
203
+ else:
204
+ # 所有行都发送
205
+ for line in lines:
206
+ if line.strip():
207
+ await websocket.send_text(line.rstrip())
208
+ last_size += len(new_content.encode("utf-8"))
209
+ except UnicodeDecodeError as e:
210
+ # 遇到编码错误时,跳过这部分内容
211
+ log.warning(f"WebSocket日志读取编码错误: {e}, 跳过部分内容")
212
+ last_size = current_size
213
+ except Exception as e:
214
+ await websocket.send_text(f"Error reading new content: {e}")
215
+ # 发生其他错误时,重置文件位置
216
+ last_size = current_size
217
+
218
+ # 如果文件被截断(如清空日志),重置位置
219
+ elif current_size < last_size:
220
+ last_size = 0
221
+ await websocket.send_text("--- 日志已清空 ---")
222
+
223
+ finally:
224
+ # 确保清理监听任务
225
+ if not listener_task.done():
226
+ listener_task.cancel()
227
+ try:
228
+ await listener_task
229
+ except asyncio.CancelledError:
230
+ pass
231
+
232
+ except WebSocketDisconnect:
233
+ pass
234
+ except Exception as e:
235
+ log.error(f"WebSocket logs error: {e}")
236
+ finally:
237
+ manager.disconnect(websocket)
src/panel/root.py ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 根路由模块 - 处理控制面板主页
3
+ """
4
+
5
+ from fastapi import APIRouter, HTTPException, Request
6
+ from fastapi.responses import HTMLResponse
7
+
8
+ from log import log
9
+ from .utils import is_mobile_user_agent
10
+
11
+
12
+ # 创建路由器
13
+ router = APIRouter(tags=["root"])
14
+
15
+
16
+ @router.get("/", response_class=HTMLResponse)
17
+ async def serve_control_panel(request: Request):
18
+ """提供统一控制面板"""
19
+ try:
20
+ user_agent = request.headers.get("user-agent", "")
21
+ is_mobile = is_mobile_user_agent(user_agent)
22
+
23
+ if is_mobile:
24
+ html_file_path = "front/control_panel_mobile.html"
25
+ else:
26
+ html_file_path = "front/control_panel.html"
27
+
28
+ with open(html_file_path, "r", encoding="utf-8") as f:
29
+ html_content = f.read()
30
+ return HTMLResponse(content=html_content)
31
+
32
+ except Exception as e:
33
+ log.error(f"加载控制面板页面失败: {e}")
34
+ raise HTTPException(status_code=500, detail="服务器内部错误")
src/panel/utils.py ADDED
@@ -0,0 +1,166 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 共享工具模块 - 包含WebSocket连接管理、工具函数等
3
+ """
4
+
5
+ import os
6
+ import time
7
+ from collections import deque
8
+ from typing import Set
9
+
10
+ from fastapi import HTTPException, WebSocket
11
+ from starlette.websockets import WebSocketState
12
+
13
+ import config
14
+ from log import log
15
+
16
+
17
+ # =============================================================================
18
+ # WebSocket连接管理
19
+ # =============================================================================
20
+
21
+
22
+ class ConnectionManager:
23
+ def __init__(self, max_connections: int = 3): # 进一步降低最大连接数
24
+ # 使用双端队列严格限制内存使用
25
+ self.active_connections: deque = deque(maxlen=max_connections)
26
+ self.max_connections = max_connections
27
+ self._last_cleanup = 0
28
+ self._cleanup_interval = 120 # 120秒清理一次死连接
29
+
30
+ async def connect(self, websocket: WebSocket):
31
+ # 自动清理死连接
32
+ self._auto_cleanup()
33
+
34
+ # 限制最大连接数,防止内存无限增长
35
+ if len(self.active_connections) >= self.max_connections:
36
+ await websocket.close(code=1008, reason="Too many connections")
37
+ return False
38
+
39
+ await websocket.accept()
40
+ self.active_connections.append(websocket)
41
+ log.debug(f"WebSocket连接建立,当前连接数: {len(self.active_connections)}")
42
+ return True
43
+
44
+ def disconnect(self, websocket: WebSocket):
45
+ # 使用更高效的方式移除连接
46
+ try:
47
+ self.active_connections.remove(websocket)
48
+ except ValueError:
49
+ pass # 连接已不存在
50
+ log.debug(f"WebSocket连接断开,当前连接数: {len(self.active_connections)}")
51
+
52
+ async def send_personal_message(self, message: str, websocket: WebSocket):
53
+ try:
54
+ await websocket.send_text(message)
55
+ except Exception:
56
+ self.disconnect(websocket)
57
+
58
+ async def broadcast(self, message: str):
59
+ # 使用更高效的方式处理广播,避免索引操作
60
+ dead_connections = []
61
+ for conn in self.active_connections:
62
+ try:
63
+ await conn.send_text(message)
64
+ except Exception:
65
+ dead_connections.append(conn)
66
+
67
+ # 批量移除死连接
68
+ for dead_conn in dead_connections:
69
+ self.disconnect(dead_conn)
70
+
71
+ def _auto_cleanup(self):
72
+ """自动清理死连接"""
73
+ current_time = time.time()
74
+ if current_time - self._last_cleanup > self._cleanup_interval:
75
+ self.cleanup_dead_connections()
76
+ self._last_cleanup = current_time
77
+
78
+ def cleanup_dead_connections(self):
79
+ """清理已断开的连接"""
80
+ original_count = len(self.active_connections)
81
+ # 使用列表推导式过滤活跃连接,更高效
82
+ alive_connections = deque(
83
+ [
84
+ conn
85
+ for conn in self.active_connections
86
+ if hasattr(conn, "client_state")
87
+ and conn.client_state != WebSocketState.DISCONNECTED
88
+ ],
89
+ maxlen=self.max_connections,
90
+ )
91
+
92
+ self.active_connections = alive_connections
93
+ cleaned = original_count - len(self.active_connections)
94
+ if cleaned > 0:
95
+ log.debug(f"清理了 {cleaned} 个死连接,剩余连接数: {len(self.active_connections)}")
96
+
97
+
98
+ # =============================================================================
99
+ # 工具函数
100
+ # =============================================================================
101
+
102
+
103
+ def is_mobile_user_agent(user_agent: str) -> bool:
104
+ """检测是否为移动设备用户代理"""
105
+ if not user_agent:
106
+ return False
107
+
108
+ user_agent_lower = user_agent.lower()
109
+ mobile_keywords = [
110
+ "mobile",
111
+ "android",
112
+ "iphone",
113
+ "ipad",
114
+ "ipod",
115
+ "blackberry",
116
+ "windows phone",
117
+ "samsung",
118
+ "htc",
119
+ "motorola",
120
+ "nokia",
121
+ "palm",
122
+ "webos",
123
+ "opera mini",
124
+ "opera mobi",
125
+ "fennec",
126
+ "minimo",
127
+ "symbian",
128
+ "psp",
129
+ "nintendo",
130
+ "tablet",
131
+ ]
132
+
133
+ return any(keyword in user_agent_lower for keyword in mobile_keywords)
134
+
135
+
136
+ def validate_mode(mode: str = "geminicli") -> str:
137
+ """
138
+ 验证 mode 参数
139
+
140
+ Args:
141
+ mode: 模式字符串 ("geminicli" 或 "antigravity")
142
+
143
+ Returns:
144
+ str: 验证后的 mode 字符串
145
+
146
+ Raises:
147
+ HTTPException: 如果 mode 参数无效
148
+ """
149
+ if mode not in ["geminicli", "antigravity"]:
150
+ raise HTTPException(
151
+ status_code=400,
152
+ detail=f"无效的 mode 参数: {mode},只支持 'geminicli' 或 'antigravity'"
153
+ )
154
+ return mode
155
+
156
+
157
+ def get_env_locked_keys() -> Set:
158
+ """获取被环境变量锁定的配置键集合"""
159
+ env_locked_keys = set()
160
+
161
+ # 使用 config.py 中统一维护的映射表
162
+ for env_key, config_key in config.ENV_MAPPINGS.items():
163
+ if os.getenv(env_key):
164
+ env_locked_keys.add(config_key)
165
+
166
+ return env_locked_keys
src/panel/version.py ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 版本信息路由模块 - 处理 /version/* 相关的HTTP请求
3
+ """
4
+
5
+ import os
6
+
7
+ from fastapi import APIRouter
8
+ from fastapi.responses import JSONResponse
9
+
10
+ from log import log
11
+
12
+
13
+ # 创建路由器
14
+ router = APIRouter(prefix="/version", tags=["version"])
15
+
16
+
17
+ @router.get("/info")
18
+ async def get_version_info(check_update: bool = False):
19
+ """
20
+ 获取当前版本信息 - 从version.txt读取
21
+ 可选参数 check_update: 是否检查GitHub上的最新版本
22
+ """
23
+ try:
24
+ # 获取项目根目录
25
+ project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
26
+ version_file = os.path.join(project_root, "version.txt")
27
+
28
+ # 读取version.txt
29
+ if not os.path.exists(version_file):
30
+ return JSONResponse({
31
+ "success": False,
32
+ "error": "version.txt文件不存在"
33
+ })
34
+
35
+ version_data = {}
36
+ with open(version_file, 'r', encoding='utf-8') as f:
37
+ for line in f:
38
+ line = line.strip()
39
+ if '=' in line:
40
+ key, value = line.split('=', 1)
41
+ version_data[key] = value
42
+
43
+ # 检查必要字段
44
+ if 'short_hash' not in version_data:
45
+ return JSONResponse({
46
+ "success": False,
47
+ "error": "version.txt格式错误"
48
+ })
49
+
50
+ response_data = {
51
+ "success": True,
52
+ "version": version_data.get('short_hash', 'unknown'),
53
+ "full_hash": version_data.get('full_hash', ''),
54
+ "message": version_data.get('message', ''),
55
+ "date": version_data.get('date', '')
56
+ }
57
+
58
+ # 如果需要检查更新
59
+ if check_update:
60
+ try:
61
+ from src.httpx_client import get_async
62
+
63
+ # 直接获取GitHub上的version.txt文件
64
+ github_version_url = "https://raw.githubusercontent.com/su-kaka/gcli2api/refs/heads/master/version.txt"
65
+
66
+ # 使用统一的httpx客户端
67
+ resp = await get_async(github_version_url, timeout=10.0)
68
+
69
+ if resp.status_code == 200:
70
+ # 解析远程version.txt
71
+ remote_version_data = {}
72
+ for line in resp.text.strip().split('\n'):
73
+ line = line.strip()
74
+ if '=' in line:
75
+ key, value = line.split('=', 1)
76
+ remote_version_data[key] = value
77
+
78
+ latest_hash = remote_version_data.get('full_hash', '')
79
+ latest_short_hash = remote_version_data.get('short_hash', '')
80
+ current_hash = version_data.get('full_hash', '')
81
+
82
+ has_update = (current_hash != latest_hash) if current_hash and latest_hash else None
83
+
84
+ response_data['check_update'] = True
85
+ response_data['has_update'] = has_update
86
+ response_data['latest_version'] = latest_short_hash
87
+ response_data['latest_hash'] = latest_hash
88
+ response_data['latest_message'] = remote_version_data.get('message', '')
89
+ response_data['latest_date'] = remote_version_data.get('date', '')
90
+ else:
91
+ # GitHub获取失败,但不影响基本版本信息
92
+ response_data['check_update'] = False
93
+ response_data['update_error'] = f"GitHub返回错误: {resp.status_code}"
94
+
95
+ except Exception as e:
96
+ log.debug(f"检查更新失败: {e}")
97
+ response_data['check_update'] = False
98
+ response_data['update_error'] = str(e)
99
+
100
+ return JSONResponse(response_data)
101
+
102
+ except Exception as e:
103
+ log.error(f"获取版本信息失败: {e}")
104
+ return JSONResponse({
105
+ "success": False,
106
+ "error": str(e)
107
+ })