a3216 commited on
Commit
c50496f
·
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,175 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ================================================================
2
+ # GCLI2API 环境变量配置示例文件
3
+ # 复制此文件为 .env 并根据需要修改配置值
4
+ # ================================================================
5
+
6
+ # ================================================================
7
+ # 服务器配置
8
+ # ================================================================
9
+
10
+ # 服务器监听地址
11
+ # 默认: 0.0.0.0 (监听所有网络接口)
12
+ HOST=0.0.0.0
13
+
14
+ # 服务器端口
15
+ # 默认: 7861
16
+ PORT=7861
17
+
18
+ # ================================================================
19
+ # 密码配置 (支持分离密码)
20
+ # ================================================================
21
+
22
+ # 聊天API访问密码 (用于OpenAI和Gemini API端点认证)
23
+ # 默认: 继承通用密码或 pwd
24
+ API_PASSWORD=your_api_password
25
+
26
+ # 控制面板访问密码 (用于Web界面登录认证)
27
+ # 默认: 继承通用密码或 pwd
28
+ PANEL_PASSWORD=your_panel_password
29
+
30
+ # 通用访问密码 (兼容性保留)
31
+ # 设置后会覆盖上述两个专用密码,优先级最高
32
+ # 如果只想使用一个密码,设置此项即可
33
+ # 默认: pwd
34
+ PASSWORD=pwd
35
+
36
+ # ================================================================
37
+ # 存储配置
38
+ # ================================================================
39
+
40
+ # 存储后端优先级: PostgreSQL > MongoDB > 本地sqlite文件存储
41
+ # 系统会自动选择可用的最高优先级存储后端
42
+
43
+ # PostgreSQL 分布式存储模式配置 (最高优先级)
44
+ # 设置 POSTGRESQL_URI 后自动启用 PostgreSQL 模式
45
+ # 本地 PostgreSQL: postgresql://user:password@localhost:5432/gcli2api
46
+ # 带 SSL: postgresql://user:password@host:5432/gcli2api?sslmode=require
47
+ # 默认: 无 (不启用 PostgreSQL 存储)
48
+ POSTGRESQL_URI=postgresql://user:password@localhost:5432/gcli2api
49
+
50
+ # MongoDB 分布式存储模式配置 (第二优先级)
51
+ # 设置 MONGODB_URI 后自动启用 MongoDB 模式,不再使用本地文件存储
52
+
53
+ # Redis 缓存存储配置
54
+ # 设置 REDIS_URL 后自动启用 Redis 模式,性能最佳,可大幅降低 MongoDB 的读写压力
55
+ # 本地 Redis: redis://127.0.0.1:6379/0
56
+ # 带密码: redis://:password@127.0.0.1:6379/0
57
+ # 默认: 无 (不启用 Redis 缓存)
58
+ REDIS_URL=redis://127.0.0.1:6379/0
59
+
60
+ # MongoDB 连接字符串 (设置后启用 MongoDB 分布式存储模式)
61
+ # 本地 MongoDB: mongodb://localhost:27017
62
+ # 带认证: mongodb://admin:password@localhost:27017/admin
63
+ # MongoDB Atlas: mongodb+srv://username:password@cluster.mongodb.net
64
+ # 副本集: mongodb://host1:27017,host2:27017,host3:27017/gcli2api?replicaSet=rs0
65
+ # 默认: 无 (使用本地文件存储)
66
+ MONGODB_URI=mongodb://localhost:27017
67
+
68
+ # MongoDB 数据库名称 (仅在启用 MongoDB 模式时有效)
69
+ # 默认: gcli2api
70
+ MONGODB_DATABASE=gcli2api
71
+
72
+ # ================================================================
73
+ # Google API 配置
74
+ # ================================================================
75
+
76
+ # 凭证文件目录 (仅在文件存储模式下使用)
77
+ # 默认: ./creds
78
+ CREDENTIALS_DIR=./creds
79
+
80
+
81
+ # 代理配置 (可选)
82
+ # 支持 http, https, socks5 代理
83
+ # 格式: http://proxy:port, https://proxy:port, socks5://proxy:port
84
+ PROXY=http://localhost:7890
85
+
86
+ # Google API 代理 URL 配置 (可选)
87
+
88
+ # Google Code Assist API 端点
89
+ # 默认: https://cloudcode-pa.googleapis.com
90
+ CODE_ASSIST_ENDPOINT=https://cloudcode-pa.googleapis.com
91
+ # 用于Google OAuth2认证的代理URL
92
+ # 默认: https://oauth2.googleapis.com
93
+ OAUTH_PROXY_URL=https://oauth2.googleapis.com
94
+
95
+ # 用于Google APIs调用的代理URL
96
+ # 默认: https://www.googleapis.com
97
+ GOOGLEAPIS_PROXY_URL=https://www.googleapis.com
98
+
99
+ # 用于Google Cloud Resource Manager API的URL
100
+ # 默认: https://cloudresourcemanager.googleapis.com
101
+ RESOURCE_MANAGER_API_URL=https://cloudresourcemanager.googleapis.com
102
+
103
+ # 用于Google Cloud Service Usage API的URL
104
+ # 默认: https://serviceusage.googleapis.com
105
+ SERVICE_USAGE_API_URL=https://serviceusage.googleapis.com
106
+
107
+ # ================================================================
108
+ # 错误处理和重试配置
109
+ # ================================================================
110
+
111
+ # 是否启用自动封禁功能
112
+ # 当凭证返回特定错误码时自动禁用该凭证
113
+ # 默认: false
114
+ AUTO_BAN=false
115
+
116
+ # 自动封禁的错误码列表 (逗号分隔)
117
+ # 默认: 400,403
118
+ AUTO_BAN_ERROR_CODES=403
119
+
120
+ # 是否启用 429 错误重试
121
+ # 默认: true
122
+ RETRY_429_ENABLED=true
123
+
124
+ # 429 错误最大重试次数
125
+ # 默认: 5
126
+ RETRY_429_MAX_RETRIES=5
127
+
128
+ # 429 错误重试间隔 (秒)
129
+ # 默认: 1
130
+ RETRY_429_INTERVAL=1
131
+
132
+ # ================================================================
133
+ # 日志配置
134
+ # ================================================================
135
+
136
+ # 日志级别
137
+ # 可选值: debug, info, warning, error, critical
138
+ # 默认: info
139
+ LOG_LEVEL=info
140
+
141
+ # 日志文件路径
142
+ # 默认: log.txt
143
+ LOG_FILE=log.txt
144
+
145
+ # ================================================================
146
+ # 高级功能配置
147
+ # ================================================================
148
+
149
+ # 流式抗截断最大尝试次数
150
+ # 用于 "流式抗截断/" 前缀的模型
151
+ # 默认: 3
152
+ ANTI_TRUNCATION_MAX_ATTEMPTS=3
153
+
154
+ # ================================================================
155
+ # 环境变量使用说明
156
+ # ================================================================
157
+
158
+ # 1. 存储模式配置 (按优先级自动选择):
159
+ # - PostgreSQL 分布式模式 (最高优先级): 设置 POSTGRESQL_URI,数据存储在 PostgreSQL 数据库
160
+ # - Redis 缓存: 同时设置 REDIS_URI和 MONGODB_URI时,数据缓存在 Redis 数据库,持久化在MONGODB,性能最佳
161
+ # - MongoDB 分布式模式: 设置 MONGODB_URI,数据存储在 MongoDB 数据库
162
+ # - 文件存储模式 (默认): 不设置上述 URI,数据存储在本地 creds/ 目录
163
+ # - 自动切换: 系统根据可用的存储配置自动选择最高优先级的存储后端
164
+
165
+ # 2. 密码配置优先级:
166
+ # a) PASSWORD 环境变量 (最高优先级,设置后覆盖其他密码)
167
+ # b) API_PASSWORD / PANEL_PASSWORD 环境变量 (专用密码)
168
+ # c) 默认值 "pwd"
169
+ #
170
+ # 3. 通用配置优先级:
171
+ # 环境变量 > 默认值
172
+
173
+ # 4. 布尔值环境变量:
174
+ # true/1/yes/on 表示启用
175
+ # false/0/no/off 表示禁用
.github/ISSUE_TEMPLATE/bug_report.yml ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Bug 报告
2
+ description: 报告项目使用中遇到的问题
3
+ title: "[Bug]: "
4
+ labels: ["bug", "待处理"]
5
+ body:
6
+ - type: markdown
7
+ attributes:
8
+ value: |
9
+ ## 感谢你的反馈!
10
+ 请填写以下信息以帮助我们更快定位问题。
11
+
12
+ - type: checkboxes
13
+ id: checklist
14
+ attributes:
15
+ label: 提交前确认
16
+ options:
17
+ - label: 我已经搜索过现有的 issues,确认这不是重复问题
18
+ required: true
19
+ - label: 我已经阅读过项目文档
20
+ required: true
21
+
22
+ - type: dropdown
23
+ id: latest-version
24
+ attributes:
25
+ label: 是否是最新版
26
+ description: 请确认你使用的是否是最新版本
27
+ options:
28
+ - 是,使用最新版
29
+ - 否,使用旧版本
30
+ validations:
31
+ required: true
32
+
33
+ - type: input
34
+ id: channel
35
+ attributes:
36
+ label: 调用的是哪个渠道
37
+ description: 例如 geminicli 或者 antigravity
38
+ placeholder: "例如: geminicli"
39
+ validations:
40
+ required: true
41
+
42
+ - type: input
43
+ id: model
44
+ attributes:
45
+ label: 调用的是哪个模型
46
+ description: 例如 gemini-2.5-flash
47
+ placeholder: "例如: gemini-2.5-flash"
48
+ validations:
49
+ required: true
50
+
51
+ - type: dropdown
52
+ id: format
53
+ attributes:
54
+ label: 调用的是哪个格式
55
+ description: 选择你使用的 API 格式
56
+ options:
57
+ - gemini 格式
58
+ - openai 格式
59
+ - claude 格式
60
+ - 其他格式
61
+ validations:
62
+ required: true
63
+
64
+ - type: textarea
65
+ id: error-content
66
+ attributes:
67
+ label: 具体报错内容
68
+ description: 请粘贴完整的错误信息或截图
69
+ placeholder: |
70
+ 请在这里粘贴完整的错误日志或堆栈信息
71
+ render: shell
72
+ validations:
73
+ required: true
74
+
75
+ - type: textarea
76
+ id: error-description
77
+ attributes:
78
+ label: 错误描述
79
+ description: 详细描述问题的发生场景、预期行为和实际行为
80
+ placeholder: |
81
+ 1. 我在做什么操作时遇到了这个问题
82
+ 2. 我期望的结果是...
83
+ 3. 但实际上发生了...
84
+ validations:
85
+ required: true
86
+
87
+ - type: textarea
88
+ id: additional-context
89
+ attributes:
90
+ label: 补充信息(可选)
91
+ description: 其他任何有助于解决问题的信息
92
+ placeholder: 例如:操作系统、Python 版本、相关配置等
.github/ISSUE_TEMPLATE/config.yml ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ blank_issues_enabled: false
2
+ contact_links:
3
+ - name: 使用问题讨论
4
+ url: https://github.com/su-kaka/gcli2api/issues
5
+ about: 如果是使用方面的问题,请在 issues 中提问
6
+ - name: 项目文档
7
+ url: https://github.com/su-kaka/gcli2api
8
+ about: 查看完整文档和使用指南
.github/workflows/docker-publish.yml ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Docker Build and Publish
2
+
3
+ on:
4
+ workflow_run:
5
+ workflows: ["Update Version File"]
6
+ types:
7
+ - completed
8
+ branches:
9
+ - master
10
+ - main
11
+ push:
12
+ tags:
13
+ - 'v*'
14
+ pull_request:
15
+ branches:
16
+ - master
17
+ - main
18
+ workflow_dispatch:
19
+
20
+ env:
21
+ REGISTRY: ghcr.io
22
+ IMAGE_NAME: ${{ github.repository }}
23
+
24
+ jobs:
25
+ build-and-push:
26
+ runs-on: ubuntu-latest
27
+ # 只在 workflow_run 成功时运行,或者非 workflow_run 触发时运行
28
+ if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }}
29
+ permissions:
30
+ contents: read
31
+ packages: write
32
+
33
+ steps:
34
+ - name: Checkout repository
35
+ uses: actions/checkout@v4
36
+ with:
37
+ # workflow_run 触发时需要获取最新的代码(包括 version.txt 的更新)
38
+ ref: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_branch || github.ref }}
39
+
40
+ - name: Set up QEMU
41
+ uses: docker/setup-qemu-action@v3
42
+
43
+ - name: Set up Docker Buildx
44
+ uses: docker/setup-buildx-action@v3
45
+
46
+ - name: Log in to GitHub Container Registry
47
+ uses: docker/login-action@v3
48
+ with:
49
+ registry: ${{ env.REGISTRY }}
50
+ username: ${{ github.actor }}
51
+ password: ${{ secrets.GITHUB_TOKEN }}
52
+
53
+ - name: Extract metadata
54
+ id: meta
55
+ uses: docker/metadata-action@v5
56
+ with:
57
+ images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
58
+ tags: |
59
+ type=ref,event=branch
60
+ type=ref,event=tag
61
+ type=ref,event=pr
62
+ type=raw,value=latest,enable={{is_default_branch}}
63
+ type=sha,prefix={{branch}}-
64
+ type=semver,pattern={{version}}
65
+ type=semver,pattern={{major}}.{{minor}}
66
+ type=semver,pattern={{major}}
67
+
68
+ - name: Build and push Docker image
69
+ uses: docker/build-push-action@v5
70
+ with:
71
+ context: .
72
+ platforms: linux/amd64,linux/arm64
73
+ push: ${{ github.event_name != 'pull_request' }}
74
+ tags: ${{ steps.meta.outputs.tags }}
75
+ labels: ${{ steps.meta.outputs.labels }}
76
+ cache-from: type=gha
77
+ cache-to: type=gha,mode=max
78
+ build-args: |
79
+ BUILD_DATE=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.created'] }}
80
+ VERSION=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.version'] }}
81
+ REVISION=${{ github.sha }}
.github/workflows/update-version.yml ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Update Version File
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - master
7
+ - main
8
+
9
+ jobs:
10
+ update-version:
11
+ runs-on: ubuntu-latest
12
+ permissions:
13
+ contents: write
14
+
15
+ steps:
16
+ - name: Checkout repository
17
+ uses: actions/checkout@v4
18
+ with:
19
+ fetch-depth: 0
20
+ token: ${{ secrets.GITHUB_TOKEN }}
21
+
22
+ - name: Update version.txt
23
+ run: |
24
+ # 获取最新commit信息
25
+ FULL_HASH=$(git log -1 --format=%H)
26
+ SHORT_HASH=$(git log -1 --format=%h)
27
+ MESSAGE=$(git log -1 --format=%s)
28
+ DATE=$(git log -1 --format=%ci)
29
+
30
+ # 写入version.txt
31
+ echo "full_hash=$FULL_HASH" > version.txt
32
+ echo "short_hash=$SHORT_HASH" >> version.txt
33
+ echo "message=$MESSAGE" >> version.txt
34
+ echo "date=$DATE" >> version.txt
35
+
36
+ echo "Version file updated:"
37
+ cat version.txt
38
+
39
+ - name: Commit version.txt if changed
40
+ run: |
41
+ git config --local user.email "github-actions[bot]@users.noreply.github.com"
42
+ git config --local user.name "github-actions[bot]"
43
+
44
+ # 检查是否有变化
45
+ if git diff --quiet version.txt; then
46
+ echo "No changes to version.txt"
47
+ else
48
+ git add version.txt
49
+ git commit -m "chore: update version.txt [skip ci]"
50
+ git push
51
+ fi
.gitignore ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Credential files - should never be committed
2
+ *.json
3
+ !package.json
4
+ !package-lock.json
5
+ !tsconfig.json
6
+ *.toml
7
+ !pyproject.toml
8
+ creds/
9
+ CLAUDE.md
10
+ GEMINI.md
11
+ .kiro
12
+ # Environment configuration
13
+ .env
14
+
15
+ # Python
16
+ uv.lock
17
+ .python-version
18
+ __pycache__/
19
+ *.py[cod]
20
+ *$py.class
21
+ *.so
22
+ .Python
23
+ build/
24
+ develop-eggs/
25
+ dist/
26
+ downloads/
27
+ eggs/
28
+ .eggs/
29
+ lib/
30
+ lib64/
31
+ parts/
32
+ sdist/
33
+ var/
34
+ wheels/
35
+ pip-wheel-metadata/
36
+ share/python-wheels/
37
+ *.egg-info/
38
+ .installed.cfg
39
+ *.egg
40
+ MANIFEST
41
+
42
+ # PyInstaller
43
+ *.manifest
44
+ *.spec
45
+
46
+ # Installer logs
47
+ pip-log.txt
48
+ pip-delete-this-directory.txt
49
+
50
+ # Unit test / coverage reports
51
+ htmlcov/
52
+ .tox/
53
+ .nox/
54
+ .coverage
55
+ .coverage.*
56
+ .cache
57
+ nosetests.xml
58
+ coverage.xml
59
+ *.cover
60
+ *.py,cover
61
+ .hypothesis/
62
+ .pytest_cache/
63
+
64
+ # Virtual environments
65
+ .env
66
+ .venv
67
+ env/
68
+ venv/
69
+ ENV/
70
+ env.bak/
71
+ venv.bak/
72
+
73
+ # IDE
74
+ .vscode/
75
+ .idea/
76
+ .claude/
77
+ *.swp
78
+ *.swo
79
+ *~
80
+
81
+ # OS
82
+ .DS_Store
83
+ .DS_Store?
84
+ ._*
85
+ .Spotlight-V100
86
+ .Trashes
87
+ ehthumbs.db
88
+ Thumbs.db
89
+
90
+ # Logs
91
+ *.log
92
+ log.txt
93
+
94
+ # Temporary files
95
+ *.tmp
96
+ *.temp
97
+ *.bak
98
+
99
+ tools/
CONTRIBUTING.md ADDED
@@ -0,0 +1,169 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Contributing to gcli2api
2
+
3
+ First off, thank you for considering contributing to gcli2api! It's people like you that make gcli2api such a great tool.
4
+
5
+ ## Code of Conduct
6
+
7
+ This project is intended for personal learning and research purposes only. By participating, you are expected to uphold this code and respect the CNC-1.0 license restrictions on commercial use.
8
+
9
+ ## How Can I Contribute?
10
+
11
+ ### Reporting Bugs
12
+
13
+ Before creating bug reports, please check the existing issues to avoid duplicates. When you create a bug report, include as many details as possible:
14
+
15
+ * **Use a clear and descriptive title**
16
+ * **Describe the exact steps to reproduce the problem**
17
+ * **Provide specific examples** - Include code snippets, configuration files, or log outputs
18
+ * **Describe the behavior you observed** and what you expected to see
19
+ * **Include environment details**: OS, Python version, Docker version (if applicable)
20
+
21
+ ### Suggesting Enhancements
22
+
23
+ Enhancement suggestions are tracked as GitHub issues. When creating an enhancement suggestion, include:
24
+
25
+ * **Use a clear and descriptive title**
26
+ * **Provide a detailed description** of the suggested enhancement
27
+ * **Explain why this enhancement would be useful**
28
+ * **List any alternative solutions** you've considered
29
+
30
+ ### Pull Requests
31
+
32
+ 1. Fork the repo and create your branch from `master`
33
+ 2. If you've added code that should be tested, add tests
34
+ 3. If you've changed APIs, update the documentation
35
+ 4. Ensure the test suite passes
36
+ 5. Make sure your code follows the existing style
37
+ 6. Write a clear commit message
38
+
39
+ ## Development Setup
40
+
41
+ ### Prerequisites
42
+
43
+ * Python 3.12 or higher
44
+ * pip or uv package manager
45
+
46
+ ### Setting Up Your Development Environment
47
+
48
+ ```bash
49
+ # Clone your fork
50
+ git clone https://github.com/YOUR_USERNAME/gcli2api.git
51
+ cd gcli2api
52
+
53
+ # Install development dependencies
54
+ make install-dev
55
+ # or
56
+ pip install -e ".[dev]"
57
+
58
+ # Copy environment example
59
+ cp .env.example .env
60
+ # Edit .env with your configuration
61
+ ```
62
+
63
+ ### Development Workflow
64
+
65
+ ```bash
66
+ # Run tests
67
+ make test
68
+
69
+ # Format code
70
+ make format
71
+
72
+ # Run linters
73
+ make lint
74
+
75
+ # Run the application locally
76
+ make run
77
+ ```
78
+
79
+ ### Testing
80
+
81
+ We use pytest for testing. All new features should include appropriate tests.
82
+
83
+ ```bash
84
+ # Run all tests
85
+ make test
86
+
87
+ # Run with coverage
88
+ make test-cov
89
+
90
+ # Run specific test file
91
+ python -m pytest test_tool_calling.py -v
92
+ ```
93
+
94
+ ### Code Style
95
+
96
+ * We use [Black](https://black.readthedocs.io/) for code formatting (line length: 100)
97
+ * We use [flake8](https://flake8.pycqa.org/) for linting
98
+ * We use [mypy](http://mypy-lang.org/) for type checking (optional, but encouraged)
99
+
100
+ ```bash
101
+ # Format your code before committing
102
+ make format
103
+
104
+ # Check if code is properly formatted
105
+ make format-check
106
+
107
+ # Run linters
108
+ make lint
109
+ ```
110
+
111
+ ## Project Structure
112
+
113
+ ```
114
+ gcli2api/
115
+ ├── src/ # Main source code
116
+ │ ├── auth.py # Authentication and OAuth
117
+ │ ├── credential_manager.py # Credential rotation
118
+ │ ├── openai_router.py # OpenAI-compatible endpoints
119
+ │ ├── gemini_router.py # Gemini native endpoints
120
+ │ ├── openai_transfer.py # Format conversion
121
+ │ ├── storage/ # Storage backends (Redis, MongoDB, Postgres, File)
122
+ │ └── ...
123
+ ├── front/ # Frontend static files
124
+ ├── tests/ # Test directory (to be created)
125
+ ├── test_*.py # Test files (root level)
126
+ ├── web.py # Main application entry point
127
+ ├── config.py # Configuration management
128
+ └── requirements.txt # Production dependencies
129
+ ```
130
+
131
+ ## Coding Guidelines
132
+
133
+ ### Python Style
134
+
135
+ * Follow PEP 8 guidelines
136
+ * Use type hints where appropriate
137
+ * Write docstrings for classes and functions
138
+ * Keep functions focused and concise
139
+
140
+ ### Commit Messages
141
+
142
+ * Use the present tense ("Add feature" not "Added feature")
143
+ * Use the imperative mood ("Move cursor to..." not "Moves cursor to...")
144
+ * Limit the first line to 72 characters or less
145
+ * Reference issues and pull requests liberally after the first line
146
+
147
+ ### Documentation
148
+
149
+ * Update the README.md if you change functionality
150
+ * Comment your code where necessary
151
+ * Update the .env.example if you add new configuration options
152
+
153
+ ## License
154
+
155
+ By contributing to gcli2api, you agree that your contributions will be licensed under the CNC-1.0 license. This is a strict anti-commercial license - see [LICENSE](LICENSE) for details.
156
+
157
+ ### Important License Restrictions
158
+
159
+ * ❌ No commercial use
160
+ * ❌ No use by companies with revenue > $1M USD
161
+ * ❌ No use by VC-backed or publicly traded companies
162
+ * ✅ Personal learning, research, and educational use only
163
+ * ✅ Open source integration (must follow same license)
164
+
165
+ ## Questions?
166
+
167
+ Feel free to open an issue with your question or reach out to the maintainers.
168
+
169
+ Thank you for contributing! 🎉
Dockerfile ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Multi-stage build for gcli2api
2
+ FROM python:3.13-slim as base
3
+
4
+ # Set environment variables
5
+ ENV PYTHONUNBUFFERED=1 \
6
+ PYTHONDONTWRITEBYTECODE=1 \
7
+ PIP_NO_CACHE_DIR=1 \
8
+ PIP_DISABLE_PIP_VERSION_CHECK=1 \
9
+ TZ=Asia/Shanghai
10
+
11
+ # Install tzdata and set timezone
12
+ RUN apt-get update && \
13
+ apt-get install -y --no-install-recommends tzdata && \
14
+ ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && \
15
+ echo "Asia/Shanghai" > /etc/timezone && \
16
+ apt-get clean && \
17
+ rm -rf /var/lib/apt/lists/*
18
+
19
+ WORKDIR /app
20
+
21
+ # Copy only requirements first for better caching
22
+ COPY requirements.txt .
23
+
24
+ # Install Python dependencies
25
+ RUN pip install --no-cache-dir -r requirements.txt
26
+
27
+ # Copy application code
28
+ COPY . .
29
+
30
+ # Expose port
31
+ EXPOSE 7861
32
+
33
+ # Default command
34
+ CMD ["python", "web.py"]
LICENSE ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Cooperative Non-Commercial License (CNC-1.0)
2
+
3
+ Copyright (c) 2024 gcli2api contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person or organization
6
+ obtaining a copy of this software and associated documentation files (the
7
+ "Software"), to use, copy, modify, merge, publish, distribute, and/or
8
+ sublicense the Software, subject to the following conditions:
9
+
10
+ TERMS AND CONDITIONS:
11
+
12
+ 1. NON-COMMERCIAL USE ONLY
13
+ The Software may only be used for non-commercial purposes. Commercial use
14
+ is strictly prohibited without explicit written permission from the
15
+ copyright holders.
16
+
17
+ 2. DEFINITION OF COMMERCIAL USE
18
+ "Commercial use" includes but is not limited to:
19
+ a) Using the Software to provide paid services or products
20
+ b) Integrating the Software into commercial products or services
21
+ c) Using the Software in any business operation that generates revenue
22
+ d) Offering the Software as part of a paid subscription or service
23
+ e) Using the Software to compete with the original project commercially
24
+
25
+ 3. COPYLEFT REQUIREMENT
26
+ Any derivative works, modifications, or substantial portions of the Software
27
+ must be licensed under the same or substantially similar terms. This ensures
28
+ that all derivatives remain non-commercial and freely available.
29
+
30
+ 4. SOURCE CODE AVAILABILITY
31
+ If you distribute the Software or any derivative works, you must make the
32
+ complete source code available under the same license terms at no charge.
33
+
34
+ 5. ATTRIBUTION REQUIREMENT
35
+ You must retain all copyright notices, license notices, and attribution
36
+ statements in all copies or substantial portions of the Software.
37
+
38
+ 6. ANTI-CORPORATE CLAUSE
39
+ This Software may not be used by corporations with annual revenue exceeding
40
+ $1 million USD, venture capital backed companies, or publicly traded
41
+ companies without explicit written permission from the copyright holders.
42
+
43
+ 7. EDUCATIONAL AND RESEARCH EXEMPTION
44
+ Use by educational institutions, non-profit research organizations, and
45
+ individual researchers for educational or research purposes is explicitly
46
+ permitted and encouraged.
47
+
48
+ 8. MODIFICATION AND CONTRIBUTION
49
+ Modifications and contributions to the Software are welcomed and encouraged,
50
+ provided they comply with these license terms. Contributors grant the same
51
+ license to their contributions.
52
+
53
+ 9. PATENT GRANT
54
+ Each contributor grants you a non-exclusive, worldwide, royalty-free patent
55
+ license to make, have made, use, offer to sell, sell, import, and otherwise
56
+ transfer the Work for non-commercial purposes only.
57
+
58
+ 10. TERMINATION
59
+ This license automatically terminates if you violate any of its terms.
60
+ Upon termination, you must cease all use and distribution of the Software
61
+ and destroy all copies in your possession.
62
+
63
+ 11. LIABILITY DISCLAIMER
64
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
65
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
66
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
67
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
68
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
69
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
70
+ SOFTWARE.
71
+
72
+ 12. JURISDICTION
73
+ This license shall be governed by and construed in accordance with the laws
74
+ of the jurisdiction where the copyright holder resides.
75
+
76
+ SUMMARY:
77
+ This license allows free use, modification, and distribution of the Software
78
+ for non-commercial purposes only. It explicitly prohibits commercial use and
79
+ ensures that all derivatives remain freely available under the same terms.
80
+ The license promotes cooperative development while preventing commercial
81
+ exploitation of the community's work.
82
+
83
+ For commercial licensing inquiries, please contact the copyright holders.
README.md ADDED
@@ -0,0 +1,768 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: gcli2api
3
+ colorFrom: blue
4
+ colorTo: green
5
+ sdk: docker
6
+ app_port: 7861
7
+ pinned: false
8
+ ---
9
+ # GeminiCLI to API
10
+
11
+ **灏?GeminiCLI 鍜?Antigravity 杞崲涓?OpenAI 銆丟EMINI 鍜?Claude API 鍏煎鎺ュ彛**
12
+
13
+ [![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
+ ## 鈿狅笍 璁稿彲璇佸0鏄?
26
+
27
+ **鏈」鐩噰鐢?Cooperative Non-Commercial License (CNC-1.0)**
28
+
29
+ 杩欐槸涓€涓弽鍟嗕笟鍖栫殑涓ユ牸寮€婧愬崗璁紝璇︽儏璇锋煡鐪?[LICENSE](LICENSE) 鏂囦欢銆?
30
+
31
+ ### 鉁?鍏佽鐨勭敤閫旓細
32
+ - 涓汉瀛︿範銆佺爺绌躲€佹暀鑲茬敤閫?
33
+ - 闈炶惀鍒╃粍缁囦娇鐢?
34
+ - 寮€婧愰」鐩泦鎴愶紙闇€閬靛惊鐩稿悓鍗忚锛?
35
+ - 瀛︽湳鐮旂┒鍜岃鏂囧彂琛?
36
+
37
+ ### 鉂?绂佹鐨勭敤閫旓細
38
+ - 浠讳綍褰㈠紡鐨勫晢涓氫娇鐢?
39
+ - 骞存敹鍏ヨ秴杩?00涓囩編鍏冪殑浼佷笟浣跨敤
40
+ - 椋庢姇鏀寔鎴栧叕寮€浜ゆ槗鐨勫叕鍙镐娇鐢?
41
+ - 鎻愪緵浠樿垂鏈嶅姟鎴栦骇鍝?
42
+ - 鍟嗕笟绔炰簤鐢ㄩ€?
43
+
44
+ ## 鏍稿績鍔熻兘
45
+
46
+ ### 馃攧 API 绔偣鍜屾牸寮忔敮鎸?
47
+
48
+ **澶氱鐐瑰鏍煎紡鏀寔**
49
+ - **OpenAI 鍏煎绔偣**锛歚/v1/chat/completions` 鍜?`/v1/models`
50
+ - 鏀寔鏍囧噯 OpenAI 鏍煎紡锛坢essages 缁撴瀯锛?
51
+ - 鏀寔 Gemini 鍘熺敓鏍煎紡锛坈ontents 缁撴瀯锛?
52
+ - 鑷姩鏍煎紡妫€娴嬪拰杞崲锛屾棤闇€鎵嬪姩鍒囨崲
53
+ - 鏀寔澶氭ā鎬佽緭鍏ワ紙鏂囨湰 + 鍥惧儚锛?
54
+ - **Gemini 鍘熺敓绔偣**锛歚/v1/models/{model}:generateContent` 鍜?`streamGenerateContent`
55
+ - 鏀寔瀹屾暣鐨?Gemini 鍘熺敓 API 瑙勮寖
56
+ - 澶氱璁よ瘉鏂瑰紡锛欱earer Token銆亁-goog-api-key 澶撮儴銆乁RL 鍙傛暟 key
57
+ - **Claude 鏍煎紡鍏煎**锛氬畬鏁存敮鎸?Claude API 鏍煎紡
58
+ - 绔偣锛歚/v1/messages`锛堥伒寰?Claude API 瑙勮寖锛?
59
+ - 鏀寔 Claude 鏍囧噯鐨?messages 鏍煎紡
60
+ - 鏀寔 system 鍙傛暟鍜?Claude 鐗规湁鍔熻兘
61
+ - 鑷姩杞崲涓哄悗绔敮鎸佺殑鏍煎紡
62
+ - **Antigravity API 鏀寔**锛氬悓鏃舵敮鎸?OpenAI銆丟emini 鍜?Claude 鏍煎紡
63
+ - OpenAI 鏍煎紡绔偣锛歚/antigravity/v1/chat/completions`
64
+ - Gemini 鏍煎紡绔偣锛歚/antigravity/v1/models/{model}:generateContent` 鍜?`streamGenerateContent`
65
+ - Claude 鏍煎紡绔偣锛歚/antigravity/v1/messages`
66
+ - 鏀寔鎵€鏈?Antigravity 妯″瀷锛圕laude銆丟emini 绛夛級
67
+ - 鑷姩妯″瀷鍚嶇О鏄犲皠鍜屾€濈淮妯″紡妫€娴?
68
+
69
+ ### 馃攼 璁よ瘉鍜屽畨鍏ㄧ鐞?
70
+
71
+ **鐏垫椿鐨勫瘑鐮佺鐞?*
72
+ - **鍒嗙瀵嗙爜鏀寔**锛欰PI 瀵嗙爜锛堣亰澶╃鐐癸級鍜屾帶鍒堕潰鏉垮瘑鐮佸彲鐙珛璁剧疆
73
+ - **澶氱璁よ瘉鏂瑰紡**锛氭敮鎸?Authorization Bearer銆亁-goog-api-key 澶撮儴銆乁RL 鍙傛暟绛?
74
+ - **JWT Token 璁よ瘉**锛氭帶鍒堕潰鏉挎敮鎸?JWT 浠ょ墝璁よ瘉
75
+ - **鐢ㄦ埛閭鑾峰彇**锛氳嚜鍔ㄨ幏鍙栧拰鏄剧ず Google 璐︽埛閭鍦板潃
76
+
77
+ ### 馃搳 鏅鸿兘鍑瘉绠$悊绯荤粺
78
+
79
+ **楂樼骇鍑瘉绠$悊**
80
+ - 澶氫釜 Google OAuth 鍑瘉鑷姩杞崲
81
+ - 閫氳繃鍐椾綑璁よ瘉澧炲己绋冲畾鎬?
82
+ - 璐熻浇鍧囪 涓庡苟鍙戣姹傛敮鎸?
83
+ - 鑷姩鏁呴殰妫€娴嬪拰鍑瘉绂佺敤
84
+ - 鍑瘉浣跨敤缁熻鍜岄厤棰濈鐞?
85
+ - 鏀寔鎵嬪姩鍚敤/绂佺敤鍑瘉鏂囦欢
86
+ - 鎵归噺鍑瘉鏂囦欢鎿嶄綔锛堝惎鐢ㄣ€佺鐢ㄣ€佸垹闄わ級
87
+
88
+ **鍑瘉鐘舵€佺洃鎺?*
89
+ - 瀹炴椂鍑瘉鍋ュ悍妫€鏌?
90
+ - 閿欒鐮佽拷韪紙429銆?03銆?00 绛夛級
91
+ - 鑷姩灏佺鏈哄埗锛堝彲閰嶇疆锛?
92
+
93
+ ### 馃寠 娴佸紡浼犺緭鍜屽搷搴斿鐞?
94
+
95
+ **澶氱娴佸紡鏀寔**
96
+ - 鐪熸鐨勫疄鏃舵祦寮忓搷搴?
97
+ - 鍋囨祦寮忔ā寮忥紙鐢ㄤ簬鍏煎鎬э級
98
+ - 娴佸紡鎶楁埅鏂姛鑳斤紙闃叉鍥炵瓟琚埅鏂級
99
+ - 寮傛浠诲姟绠$悊鍜岃秴鏃跺鐞?
100
+
101
+ **鍝嶅簲浼樺寲**
102
+ - 鎬濈淮閾撅紙Thinking锛夊唴瀹瑰垎绂?
103
+ - 鎺ㄧ悊杩囩▼锛坮easoning_content锛夊鐞?
104
+ - 澶氳疆瀵硅瘽涓婁笅鏂囩鐞?
105
+ - 鍏煎鎬фā寮忥紙灏?system 娑堟伅杞崲涓?user 娑堟伅锛?
106
+
107
+ ### 馃帥锔?Web 绠$悊鎺у埗鍙?
108
+
109
+ **鍏ㄥ姛鑳?Web 鐣岄潰**
110
+ - OAuth 璁よ瘉娴佺▼绠$悊锛堟敮鎸?GCLI 鍜?Antigravity 鍙屾ā寮忥級
111
+ - 鍑瘉鏂囦欢涓婁紶銆佷笅杞姐€佺鐞?
112
+ - 瀹炴椂鏃ュ織鏌ョ湅锛圵ebSocket锛?
113
+ - 绯荤粺閰嶇疆绠$悊
114
+ - 浣跨敤缁熻鍜岀洃鎺ч潰鏉?
115
+ - 绉诲姩绔€傞厤鐣岄潰
116
+
117
+ **鎵归噺鎿嶄綔鏀寔**
118
+ - ZIP 鏂囦欢鎵归噺涓婁紶鍑瘉锛圙CLI 鍜?Antigravity锛?
119
+ - 鎵归噺鍚敤/绂佺敤/鍒犻櫎鍑瘉
120
+ - 鎵归噺鑾峰彇鐢ㄦ埛閭
121
+ - 鎵归噺閰嶇疆绠$悊
122
+ - 缁熶竴鎵归噺涓婁紶鐣岄潰绠$悊鎵€鏈夊嚟璇佺被鍨?
123
+
124
+ ### 馃搱 浣跨敤鐩戞帶
125
+
126
+ **瀹炴椂鐩戞帶**
127
+ - WebSocket 瀹炴椂鏃ュ織娴?
128
+ - 绯荤粺鐘舵€佺洃鎺?
129
+ - 鍑瘉鍋ュ悍鐘舵€?
130
+
131
+ ### 馃敡 楂樼骇閰嶇疆鍜岃嚜瀹氫箟
132
+
133
+ **缃戠粶鍜屼唬鐞嗛厤缃?*
134
+ - HTTP/HTTPS 浠g悊鏀寔
135
+ - 浠g悊绔偣閰嶇疆锛圤Auth銆丟oogle APIs銆佸厓鏁版嵁鏈嶅姟锛?
136
+ - 瓒呮椂鍜岄噸璇曢厤缃?
137
+ - 缃戠粶閿欒澶勭悊鍜屾仮澶?
138
+
139
+ **鎬ц兘鍜岀ǔ瀹氭€ч厤缃?*
140
+ - 429 閿欒鑷姩閲嶈瘯锛堝彲閰嶇疆闂撮殧鍜屾鏁帮級
141
+ - 鎶楁埅鏂渶澶ч噸璇曟鏁?
142
+
143
+ **鏃ュ織鍜岃皟璇?*
144
+ - 澶氱骇鏃ュ織绯荤粺锛圖EBUG銆両NFO銆乄ARNING銆丒RROR锛?
145
+ - 鏃ュ織鏂囦欢绠$悊
146
+ - 瀹炴椂鏃ュ織娴?
147
+ - 鏃ュ織涓嬭浇鍜屾竻绌?
148
+
149
+ ### 馃攧 鐜鍙橀噺鍜岄厤缃鐞?
150
+
151
+ **鐏垫椿鐨勯厤缃柟寮?*
152
+ - 鐜鍙橀噺閰嶇疆
153
+ - 鐑厤缃洿鏂帮紙閮ㄥ垎閰嶇疆椤癸級
154
+ - 閰嶇疆閿佸畾锛堢幆澧冨彉閲忎紭鍏堢骇锛?
155
+
156
+ ## 鏀寔鐨勬ā鍨?
157
+
158
+ 鎵€鏈夋ā鍨嬪潎鍏峰 1M 涓婁笅鏂囩獥鍙e閲忋€傛瘡涓嚟璇佹枃浠舵彁渚?1000 娆¤姹傞搴︺€?
159
+
160
+ ### 馃 鍩虹妯″瀷
161
+ - `gemini-2.5-pro`
162
+ - `gemini-3-pro-preview`
163
+ - `gemini-3.1-pro-preview`
164
+
165
+ ### 馃 鎬濈淮妯″瀷锛圱hinking Models锛?
166
+ - `gemini-2.5-pro-high`锛氭€濊€冩ā寮?
167
+ - `gemini-2.5-pro-low`锛氫綆鎬濊€冩ā寮?
168
+ - 鏀寔鑷畾涔夋€濊€冮绠楅厤缃?
169
+ - 鑷姩鍒嗙鎬濈淮鍐呭鍜屾渶缁堝洖绛?
170
+
171
+ ### 馃攳 鎼滅储澧炲己妯″瀷
172
+ - `gemini-2.5-pro-search`锛氶泦鎴愭悳绱㈠姛鑳界殑妯″瀷
173
+
174
+ ### 馃柤锔?鍥惧儚鐢熸垚妯″瀷锛圓ntigravity锛?
175
+ - `gemini-3.1-flash-image`锛氬熀纭€鍥惧儚鐢熸垚妯″瀷
176
+ - **鍒嗚鲸鐜囧悗缂€**锛?
177
+ - `-2k`锛?K 鍒嗚鲸鐜?
178
+ - `-4k`锛?K 楂樻竻鍒嗚鲸鐜?
179
+ - **姣斾緥鍚庣紑**锛?
180
+ - `-1x1`锛氭鏂瑰舰锛堝ご鍍忥級
181
+ - `-16x9`锛氭í灞忥紙鐢佃剳澹佺焊锛?
182
+ - `-9x16`锛氱珫灞忥紙鎵嬫満澹佺焊锛?
183
+ - `-21x9`锛氳秴瀹藉睆锛堝甫楸煎睆锛?
184
+ - `-4x3`锛氫紶缁熸樉绀哄櫒
185
+ - `-3x4`锛氱珫鐗堟捣鎶?
186
+ - **缁勫悎浣跨敤绀轰緥**锛?
187
+ - `gemini-3.1-flash-image-4k-16x9`锛?K 妯睆
188
+ - `gemini-3.1-flash-image-2k-9x16`锛?K 绔栧睆
189
+ - 涓嶆寚瀹氭瘮渚嬫椂锛孉PI 鑷姩鍐冲畾妯珫姣斾緥
190
+
191
+ ### 馃寠 鐗规畩鍔熻兘鍙樹綋
192
+ - **鍋囨祦寮忔ā寮?*锛氬湪浠讳綍妯″瀷鍚嶇О鍚庢坊鍔?`-鍋囨祦寮廯 鍚庣紑
193
+ - 渚嬶細`gemini-2.5-pro-鍋囨祦寮廯
194
+ - 鐢ㄤ簬闇€瑕佹祦寮忓搷搴斾絾鏈嶅姟绔笉鏀寔鐪熸祦寮忕殑鍦烘櫙
195
+ - **娴佸紡鎶楁埅鏂ā寮?*锛氬湪妯″瀷鍚嶇О鍓嶆坊鍔?`娴佸紡鎶楁埅鏂?` 鍓嶇紑
196
+ - 渚嬶細`娴佸紡鎶楁埅鏂?gemini-2.5-pro`
197
+ - 鑷姩妫€娴嬪搷搴旀埅鏂苟閲嶈瘯锛岀‘淇濆畬鏁村洖绛?
198
+
199
+ ### 馃敡 妯″瀷鍔熻兘鑷姩妫€娴?
200
+ - 绯荤粺鑷姩璇嗗埆妯″瀷鍚嶇О涓殑鍔熻兘鏍囪瘑
201
+ - 閫忔槑鍦板鐞嗗姛鑳芥ā寮忚浆鎹?
202
+ - 鏀寔鍔熻兘缁勫悎浣跨敤
203
+
204
+
205
+ ---
206
+
207
+ ## 瀹夎鎸囧崡
208
+
209
+ ### Termux 鐜
210
+
211
+ **鍒濆瀹夎**
212
+ ```bash
213
+ curl -o termux-install.sh "https://raw.githubusercontent.com/su-kaka/gcli2api/refs/heads/master/termux-install.sh" && chmod +x termux-install.sh && ./termux-install.sh
214
+ ```
215
+
216
+ **閲嶅惎鏈嶅姟**
217
+ ```bash
218
+ cd gcli2api
219
+ bash termux-start.sh
220
+ ```
221
+
222
+ ### Windows 鐜
223
+
224
+ **鍒濆瀹夎**
225
+ ```powershell
226
+ iex (iwr "https://raw.githubusercontent.com/su-kaka/gcli2api/refs/heads/master/install.ps1" -UseBasicParsing).Content
227
+ ```
228
+
229
+ **閲嶅惎鏈嶅姟**
230
+ 鍙屽嚮鎵ц `start.bat`
231
+
232
+ ### Linux 鐜
233
+
234
+ **鍒濆瀹夎**
235
+ ```bash
236
+ curl -o install.sh "https://raw.githubusercontent.com/su-kaka/gcli2api/refs/heads/master/install.sh" && chmod +x install.sh && ./install.sh
237
+ ```
238
+
239
+ **閲嶅惎鏈嶅姟**
240
+ ```bash
241
+ cd gcli2api
242
+ bash start.sh
243
+ ```
244
+
245
+ ### macOS 鐜
246
+
247
+ **鍒濆瀹夎**
248
+ ```bash
249
+ curl -o darwin-install.sh "https://raw.githubusercontent.com/su-kaka/gcli2api/refs/heads/master/darwin-install.sh" && chmod +x darwin-install.sh && ./darwin-install.sh
250
+ ```
251
+
252
+ **閲嶅惎鏈嶅姟**
253
+ ```bash
254
+ cd gcli2api
255
+ bash start.sh
256
+ ```
257
+
258
+ ### Docker 鐜
259
+
260
+ **Docker 杩愯鍛戒护**
261
+ ```bash
262
+ # 浣跨敤閫氱敤瀵嗙爜
263
+ docker run -d --name gcli2api --network host -e PASSWORD=pwd -e PORT=7861 -v $(pwd)/data/creds:/app/creds ghcr.io/su-kaka/gcli2api:latest
264
+
265
+ # 浣跨敤鍒嗙瀵嗙爜
266
+ docker run -d --name gcli2api --network host -e API_PASSWORD=api_pwd -e PANEL_PASSWORD=panel_pwd -e PORT=7861 -v $(pwd)/data/creds:/app/creds ghcr.io/su-kaka/gcli2api:latest
267
+ ```
268
+
269
+ **Docker Mac**
270
+ ```bash
271
+ # 浣跨敤閫氱敤瀵嗙爜
272
+ docker run -d \
273
+ --name gcli2api \
274
+ -p 7861:7861 \
275
+ -p 8080:8080 \
276
+ -e PASSWORD=pwd \
277
+ -e PORT=7861 \
278
+ -v "$(pwd)/data/creds":/app/creds \
279
+ ghcr.io/su-kaka/gcli2api:latest
280
+ ```
281
+
282
+ ```bash
283
+ # 浣跨敤鍒嗙瀵嗙爜
284
+ docker run -d \
285
+ --name gcli2api \
286
+ -p 7861:7861 \
287
+ -p 8080:8080 \
288
+ -e API_PASSWORD=api_pwd \
289
+ -e PANEL_PASSWORD=panel_pwd \
290
+ -e PORT=7861 \
291
+ -v $(pwd)/data/creds:/app/creds \
292
+ ghcr.io/su-kaka/gcli2api:latest
293
+ ```
294
+
295
+ **Docker Compose 杩愯鍛戒护**
296
+ 1. 灏嗕互涓嬪唴瀹逛繚瀛樹负 `docker-compose.yml` 鏂囦欢锛?
297
+ ```yaml
298
+ version: '3.8'
299
+
300
+ services:
301
+ gcli2api:
302
+ image: ghcr.io/su-kaka/gcli2api:latest
303
+ container_name: gcli2api
304
+ restart: unless-stopped
305
+ network_mode: host
306
+ environment:
307
+ # 浣跨敤閫氱敤瀵嗙爜锛堟帹鑽愮敤浜庣畝鍗曢儴缃诧級
308
+ - PASSWORD=pwd
309
+ - PORT=7861
310
+ # 鎴栦娇鐢ㄥ垎绂诲瘑鐮侊紙鎺ㄨ崘鐢ㄤ簬鐢熶骇鐜锛?
311
+ # - API_PASSWORD=your_api_password
312
+ # - PANEL_PASSWORD=your_panel_password
313
+ volumes:
314
+ - ./data/creds:/app/creds
315
+ healthcheck:
316
+ test: ["CMD-SHELL", "python -c \"import sys, urllib.request, os; port = os.environ.get('PORT', '7861'); req = urllib.request.Request(f'http://localhost:{port}/v1/models', headers={'Authorization': 'Bearer ' + os.environ.get('PASSWORD', 'pwd')}); sys.exit(0 if urllib.request.urlopen(req, timeout=5).getcode() == 200 else 1)\""]
317
+ interval: 30s
318
+ timeout: 10s
319
+ retries: 3
320
+ start_period: 40s
321
+ ```
322
+ 2. 鍚姩鏈嶅姟锛?
323
+ ```bash
324
+ docker-compose up -d
325
+ ```
326
+
327
+ ---
328
+
329
+ ## 閰嶇疆璇存槑
330
+
331
+ 1. 璁块棶 `http://127.0.0.1:7861` 锛堥粯璁ょ鍙o紝鍙€氳繃 PORT 鐜鍙橀噺淇敼锛?
332
+ 2. 瀹屾垚 OAuth 璁よ瘉娴佺▼锛堥粯璁ゅ瘑鐮侊細`pwd`锛屽彲閫氳繃鐜鍙橀噺淇敼锛?
333
+ - **GCLI 妯″紡**锛氱敤浜庤幏鍙?Google Cloud Gemini API 鍑瘉
334
+ - **Antigravity 妯″紡**锛氱敤浜庤幏鍙?Google Antigravity API 鍑瘉
335
+ 3. 閰嶇疆瀹㈡埛绔細
336
+
337
+ **OpenAI 鍏煎瀹㈡埛绔細**
338
+ - **绔偣鍦板潃**锛歚http://127.0.0.1:7861/v1`
339
+ - **API 瀵嗛挜**锛歚pwd`锛堥粯璁ゅ€硷紝鍙€氳繃 API_PASSWORD 鎴?PASSWORD 鐜鍙橀噺淇敼锛?
340
+
341
+ **Gemini 鍘熺敓瀹㈡埛绔細**
342
+ - **绔偣鍦板潃**锛歚http://127.0.0.1:7861`
343
+ - **璁よ瘉鏂瑰紡**锛?
344
+ - `Authorization: Bearer your_api_password`
345
+ - `x-goog-api-key: your_api_password`
346
+ - URL 鍙傛暟锛歚?key=your_api_password`
347
+
348
+ ### 馃専 鍙岃璇佹ā寮忔敮鎸?
349
+
350
+ **GCLI 璁よ瘉妯″紡**
351
+ - 鏍囧噯鐨?Google Cloud Gemini API 璁よ瘉
352
+ - 鏀寔 OAuth2.0 璁よ瘉娴佺▼
353
+ - 鑷姩鍚敤蹇呴渶鐨?Google Cloud API
354
+
355
+ **Antigravity 璁よ瘉妯″紡**
356
+ - Google Antigravity API 涓撶敤璁よ瘉
357
+ - 鐙珛鐨勫嚟璇佺鐞嗙郴缁?
358
+ - 鏀寔鎵归噺涓婁紶鍜岀鐞?
359
+ - 涓?GCLI 鍑瘉瀹屽叏闅旂
360
+
361
+ **缁熶竴绠$悊鐣岄潰**
362
+ - 鍦?鎵归噺涓婁紶"鏍囩椤典腑鍙竴娆℃€х鐞嗕袱绉嶅嚟璇?
363
+ - 涓婂崐閮ㄥ垎锛欸CLI 鍑瘉鎵归噺涓婁紶锛堣摑鑹蹭富棰橈級
364
+ - 涓嬪崐閮ㄥ垎锛欰ntigravity 鍑瘉鎵归噺涓婁紶锛堢豢鑹蹭富棰橈級
365
+ - 鍚勮嚜鐙珛鐨勫嚟璇佺鐞嗘爣绛鹃〉
366
+
367
+ ## 馃捑 鏁版嵁瀛樺偍妯″紡
368
+
369
+ ### 馃専 瀛樺偍鍚庣鏀寔
370
+
371
+ gcli2api 鏀寔涓ょ瀛樺偍鍚庣锛?*鏈湴 SQLite锛堥粯璁わ級** 鍜?**MongoDB锛堜簯绔垎甯冨紡瀛樺偍锛?*
372
+
373
+ ### 馃搧 鏈湴 SQLite 瀛樺偍锛堥粯璁わ級
374
+
375
+ **榛樿瀛樺偍鏂瑰紡**
376
+ - 鏃犻渶閰嶇疆锛屽紑绠卞嵆鐢?
377
+ - 鏁版嵁瀛樺偍鍦ㄦ湰鍦?SQLite 鏁版嵁搴撲腑
378
+ - 閫傚悎鍗曟満閮ㄧ讲鍜屼釜浜轰娇鐢?
379
+ - 鑷姩鍒涘缓鍜岀鐞嗘暟鎹簱鏂囦欢
380
+
381
+ ### 馃崈 MongoDB 浜戠瀛樺偍妯″紡
382
+
383
+ **浜戠鍒嗗竷寮忓瓨鍌ㄦ柟妗?*
384
+
385
+ 褰撻渶瑕佸瀹炰緥閮ㄧ讲鎴栦簯绔瓨鍌ㄦ椂锛屽彲浠ュ惎鐢?MongoDB 瀛樺偍妯″紡銆?
386
+
387
+ ### 鈿欙笍 鍚敤 MongoDB 妯″紡
388
+
389
+ **姝ラ 1: 閰嶇疆 MongoDB 杩炴帴**
390
+ ```bash
391
+ # 鏈湴 MongoDB
392
+ export MONGODB_URI="mongodb://localhost:27017"
393
+
394
+ # MongoDB Atlas 浜戞湇鍔?
395
+ export MONGODB_URI="mongodb+srv://username:password@cluster.mongodb.net"
396
+
397
+ # 甯﹁璇佺殑 MongoDB
398
+ export MONGODB_URI="mongodb://admin:password@localhost:27017/admin"
399
+
400
+ # 鍙€夛細鑷畾涔夋暟鎹��鍚嶇О锛堥粯璁? gcli2api锛?
401
+ export MONGODB_DATABASE="my_gcli_db"
402
+ ```
403
+
404
+ **姝ラ 2: 鍚姩搴旂敤**
405
+ ```bash
406
+ # 搴旂敤浼氳嚜鍔ㄦ娴?MongoDB 閰嶇疆骞朵娇鐢?MongoDB 瀛樺偍
407
+ python web.py
408
+ ```
409
+
410
+ **Docker 鐜浣跨敤 MongoDB**
411
+ ```bash
412
+ # 鍗曟満 MongoDB 閮ㄧ讲
413
+ docker run -d --name gcli2api \
414
+ -e MONGODB_URI="mongodb://mongodb:27017" \
415
+ -e API_PASSWORD=your_password \
416
+ --network your_network \
417
+ ghcr.io/su-kaka/gcli2api:latest
418
+
419
+ # 浣跨敤 MongoDB Atlas
420
+ docker run -d --name gcli2api \
421
+ -e MONGODB_URI="mongodb+srv://user:pass@cluster.mongodb.net/gcli2api" \
422
+ -e API_PASSWORD=your_password \
423
+ -p 7861:7861 \
424
+ ghcr.io/su-kaka/gcli2api:latest
425
+ ```
426
+
427
+ **Docker Compose 绀轰緥**
428
+ ```yaml
429
+ version: '3.8'
430
+
431
+ services:
432
+ mongodb:
433
+ image: mongo:7
434
+ container_name: gcli2api-mongodb
435
+ restart: unless-stopped
436
+ environment:
437
+ MONGO_INITDB_ROOT_USERNAME: admin
438
+ MONGO_INITDB_ROOT_PASSWORD: password123
439
+ volumes:
440
+ - mongodb_data:/data/db
441
+ ports:
442
+ - "27017:27017"
443
+
444
+ gcli2api:
445
+ image: ghcr.io/su-kaka/gcli2api:latest
446
+ container_name: gcli2api
447
+ restart: unless-stopped
448
+ depends_on:
449
+ - mongodb
450
+ environment:
451
+ - MONGODB_URI=mongodb://admin:password123@mongodb:27017/admin
452
+ - MONGODB_DATABASE=gcli2api
453
+ - API_PASSWORD=your_api_password
454
+ - PORT=7861
455
+ ports:
456
+ - "7861:7861"
457
+
458
+ volumes:
459
+ mongodb_data:
460
+ ```
461
+
462
+
463
+ ### 馃敡 楂樼骇閰嶇疆
464
+
465
+ **MongoDB 杩炴帴浼樺寲**
466
+ ```bash
467
+ # 杩炴帴姹犲拰瓒呮椂閰嶇疆
468
+ export MONGODB_URI="mongodb://localhost:27017?maxPoolSize=10&serverSelectionTimeoutMS=5000"
469
+
470
+ # 鍓湰闆嗛厤缃?
471
+ export MONGODB_URI="mongodb://host1:27017,host2:27017,host3:27017/gcli2api?replicaSet=myReplicaSet"
472
+
473
+ # 璇诲啓鍒嗙閰嶇疆
474
+ export MONGODB_URI="mongodb://localhost:27017/gcli2api?readPreference=secondaryPreferred"
475
+ ```
476
+
477
+ ### 鐜鍙橀噺閰嶇疆
478
+
479
+ **鍩虹閰嶇疆**
480
+ - `PORT`: 鏈嶅姟绔彛锛堥粯璁わ細7861锛?
481
+ - `HOST`: 鏈嶅姟鍣ㄧ洃鍚湴鍧€锛堥粯璁わ細0.0.0.0锛?
482
+
483
+ **瀵嗙爜閰嶇疆**
484
+ - `API_PASSWORD`: 鑱婂ぉ API 璁块棶瀵嗙爜锛堥粯璁わ細缁ф壙 PASSWORD 鎴?pwd锛?
485
+ - `PANEL_PASSWORD`: 鎺у埗闈㈡澘璁块棶瀵嗙爜锛堥粯璁わ細缁ф壙 PASSWORD 鎴?pwd锛?
486
+ - `PASSWORD`: 閫氱敤瀵嗙爜锛岃缃悗瑕嗙洊涓婅堪涓や釜锛堥粯璁わ細pwd锛?
487
+
488
+ **鎬ц兘鍜岀ǔ瀹氭€ч厤缃?*
489
+ - `RETRY_429_ENABLED`: 鍚敤 429 閿欒鑷姩閲嶈瘯锛堥粯璁わ細true锛?
490
+ - `RETRY_429_MAX_RETRIES`: 429 閿欒鏈€澶ч噸璇曟鏁帮紙榛樿锛?锛?
491
+ - `RETRY_429_INTERVAL`: 429 閿欒閲嶈瘯闂撮殧锛岀锛堥粯璁わ細1.0锛?
492
+ - `ANTI_TRUNCATION_MAX_ATTEMPTS`: 鎶楁埅鏂渶澶ч噸璇曟鏁帮紙榛樿锛?锛?
493
+
494
+ **缃戠粶鍜屼唬鐞嗛厤缃?*
495
+ - `PROXY`: HTTP/HTTPS 浠g悊鍦板潃锛堟牸寮忥細`http://host:port`锛?
496
+ - `OAUTH_PROXY_URL`: OAuth 璁よ瘉浠g悊绔偣
497
+ - `GOOGLEAPIS_PROXY_URL`: Google APIs 浠g悊绔偣
498
+ - `METADATA_SERVICE_URL`: 鍏冩暟鎹湇鍔′唬鐞嗙鐐?
499
+
500
+ **鑷姩鍖栭厤缃?*
501
+ - `AUTO_BAN`: 鍚敤鍑瘉鑷姩灏佺锛堥粯璁わ細true锛?
502
+ - `AUTO_LOAD_ENV_CREDS`: 鍚姩鏃惰嚜鍔ㄥ姞杞界幆澧冨彉閲忓嚟璇侊紙榛樿锛歠alse锛?
503
+
504
+ **鍏煎鎬ч厤缃?*
505
+ - `COMPATIBILITY_MODE`: 鍚敤鍏煎鎬фā寮忥紝灏?system 娑堟伅杞负 user 娑堟伅锛堥粯璁わ細false锛?
506
+
507
+ **鏃ュ織閰嶇疆**
508
+ - `LOG_LEVEL`: 鏃ュ織绾у埆锛圖EBUG/INFO/WARNING/ERROR锛岄粯璁わ細INFO锛?
509
+ - `LOG_FILE`: 鏃ュ織鏂囦欢璺緞锛堥粯璁わ細log.txt锛?
510
+
511
+ **瀛樺偍閰嶇疆**
512
+
513
+ **SQLite 閰嶇疆锛堥粯璁わ級**
514
+ - 鏃犻渶閰嶇疆锛岃嚜鍔ㄤ娇鐢ㄦ湰鍦?SQLite 鏁版嵁搴?
515
+ - 鏁版嵁搴撴枃浠惰嚜鍔ㄥ垱寤哄湪椤圭洰鐩綍
516
+
517
+ **MongoDB 閰嶇疆锛堝彲閫変簯绔瓨鍌級**
518
+ - `MONGODB_URI`: MongoDB 杩炴帴瀛楃涓诧紙璁剧疆鍚庡惎鐢?MongoDB 妯″紡锛?
519
+ - `MONGODB_DATABASE`: MongoDB 鏁版嵁搴撳悕绉帮紙榛樿锛歡cli2api锛?
520
+
521
+ **Docker 浣跨敤绀轰緥**
522
+ ```bash
523
+ # 浣跨敤閫氱敤瀵嗙爜
524
+ docker run -d --name gcli2api \
525
+ -e PASSWORD=mypassword \
526
+ -e PORT=7861 \
527
+ ghcr.io/su-kaka/gcli2api:latest
528
+
529
+ # 浣跨敤鍒嗙瀵嗙爜
530
+ docker run -d --name gcli2api \
531
+ -e API_PASSWORD=my_api_password \
532
+ -e PANEL_PASSWORD=my_panel_password \
533
+ -e PORT=7861 \
534
+ ghcr.io/su-kaka/gcli2api:latest
535
+ ```
536
+
537
+ 娉ㄦ剰锛氬綋璁剧疆浜嗗嚟璇佺幆澧冨彉閲忔椂锛岀郴缁熷皢浼樺厛浣跨敤鐜鍙橀噺涓殑鍑瘉锛屽拷鐣?`creds` 鐩綍涓殑鏂囦欢銆?
538
+
539
+ ### API 浣跨敤鏂瑰紡
540
+
541
+ 鏈湇鍔℃敮鎸佷笁濂楀畬鏁寸殑 API 绔偣锛?
542
+
543
+ #### 1. OpenAI 鍏煎绔偣锛圙CLI锛?
544
+
545
+ **绔偣锛?* `/v1/chat/completions`
546
+ **璁よ瘉锛?* `Authorization: Bearer your_api_password`
547
+
548
+ 鏀寔涓ょ璇锋眰鏍煎紡锛屼細鑷姩妫€娴嬪苟澶勭悊锛?
549
+
550
+ **OpenAI 鏍煎紡锛?*
551
+ ```json
552
+ {
553
+ "model": "gemini-2.5-pro",
554
+ "messages": [
555
+ {"role": "system", "content": "You are a helpful assistant"},
556
+ {"role": "user", "content": "Hello"}
557
+ ],
558
+ "temperature": 0.7,
559
+ "stream": true
560
+ }
561
+ ```
562
+
563
+ **Gemini 鍘熺敓鏍煎紡锛?*
564
+ ```json
565
+ {
566
+ "model": "gemini-2.5-pro",
567
+ "contents": [
568
+ {"role": "user", "parts": [{"text": "Hello"}]}
569
+ ],
570
+ "systemInstruction": {"parts": [{"text": "You are a helpful assistant"}]},
571
+ "generationConfig": {
572
+ "temperature": 0.7
573
+ }
574
+ }
575
+ ```
576
+
577
+ #### 2. Gemini 鍘熺敓绔偣锛圙CLI锛?
578
+
579
+ **闈炴祦寮忕鐐癸細** `/v1/models/{model}:generateContent`
580
+ **娴佸紡绔偣锛?* `/v1/models/{model}:streamGenerateContent`
581
+ **妯″瀷鍒楄〃锛?* `/v1/models`
582
+
583
+ **璁よ瘉鏂瑰紡锛堜换閫変竴绉嶏級锛?*
584
+ - `Authorization: Bearer your_api_password`
585
+ - `x-goog-api-key: your_api_password`
586
+ - URL 鍙傛暟锛歚?key=your_api_password`
587
+
588
+ **璇锋眰绀轰緥锛?*
589
+ ```bash
590
+ # 浣跨敤 x-goog-api-key 澶撮儴
591
+ curl -X POST "http://127.0.0.1:7861/v1/models/gemini-2.5-pro:generateContent" \
592
+ -H "x-goog-api-key: your_api_password" \
593
+ -H "Content-Type: application/json" \
594
+ -d '{
595
+ "contents": [
596
+ {"role": "user", "parts": [{"text": "Hello"}]}
597
+ ]
598
+ }'
599
+
600
+ # 浣跨敤 URL 鍙傛暟
601
+ curl -X POST "http://127.0.0.1:7861/v1/models/gemini-2.5-pro:streamGenerateContent?key=your_api_password" \
602
+ -H "Content-Type: application/json" \
603
+ -d '{
604
+ "contents": [
605
+ {"role": "user", "parts": [{"text": "Hello"}]}
606
+ ]
607
+ }'
608
+ ```
609
+
610
+ #### 3. Claude API 鏍煎紡绔偣
611
+
612
+ **绔偣锛?* `/v1/messages`
613
+ **璁よ瘉锛?* `x-api-key: your_api_password` 鎴?`Authorization: Bearer your_api_password`
614
+
615
+ **璇锋眰绀轰緥锛?*
616
+ ```bash
617
+ curl -X POST "http://127.0.0.1:7861/v1/messages" \
618
+ -H "x-api-key: your_api_password" \
619
+ -H "anthropic-version: 2023-06-01" \
620
+ -H "Content-Type: application/json" \
621
+ -d '{
622
+ "model": "gemini-2.5-pro",
623
+ "max_tokens": 1024,
624
+ "messages": [
625
+ {"role": "user", "content": "Hello, Claude!"}
626
+ ]
627
+ }'
628
+ ```
629
+
630
+ **鏀寔 system 鍙傛暟锛?*
631
+ ```json
632
+ {
633
+ "model": "gemini-2.5-pro",
634
+ "max_tokens": 1024,
635
+ "system": "You are a helpful assistant",
636
+ "messages": [
637
+ {"role": "user", "content": "Hello"}
638
+ ]
639
+ }
640
+ ```
641
+
642
+ **璇存槑锛?*
643
+ - 瀹屽叏鍏煎 Claude API 鏍煎紡瑙勮寖
644
+ - 鑷姩杞崲涓?Gemini 鏍煎紡璋冪敤鍚庣
645
+ - 鏀寔 Claude 鐨勬墍鏈夋爣鍑嗗弬鏁?
646
+ - 鍝嶅簲鏍煎紡绗﹀悎 Claude API 瑙勮寖
647
+
648
+ ## 馃搵 瀹屾暣 API 鍙傝€?
649
+
650
+ ### Web 鎺у埗鍙?API
651
+
652
+ **璁よ瘉绔偣**
653
+ - `POST /auth/login` - 鐢ㄦ埛鐧诲綍
654
+ - `POST /auth/start` - 寮€濮?OAuth 璁よ瘉锛堟敮鎸?GCLI 鍜?Antigravity 妯″紡锛?
655
+ - `POST /auth/callback` - 澶勭悊 OAuth 鍥炶皟
656
+ - `POST /auth/callback-url` - 浠庡洖璋?URL 鐩存帴瀹屾垚璁よ瘉
657
+ - `GET /auth/status/{project_id}` - 妫€鏌ヨ璇佺姸鎬?
658
+
659
+ **鍑瘉绠$悊绔偣**锛堟敮鎸?`mode=geminicli` 鎴?`mode=antigravity` 鍙傛暟锛?
660
+ - `POST /creds/upload` - 鎵归噺涓婁紶鍑瘉鏂囦欢锛堟敮鎸?JSON 鍜?ZIP锛?
661
+ - `GET /creds/status` - 鑾峰彇鍑瘉鐘舵€佸垪琛紙鏀寔鍒嗛〉鍜岀瓫閫夛級
662
+ - `GET /creds/detail/{filename}` - 鑾峰彇鍗曚釜鍑瘉璇︽儏
663
+ - `POST /creds/action` - 鍗曚釜鍑瘉鎿嶄綔锛堝惎鐢?绂佺敤/鍒犻櫎锛?
664
+ - `POST /creds/batch-action` - 鎵归噺鍑瘉鎿嶄綔
665
+ - `GET /creds/download/{filename}` - 涓嬭浇鍗曚釜鍑瘉鏂囦欢
666
+ - `GET /creds/download-all` - 鎵撳寘涓嬭浇鎵€鏈夊嚟璇?
667
+ - `POST /creds/fetch-email/{filename}` - 鑾峰彇鐢ㄦ埛閭
668
+ - `POST /creds/refresh-all-emails` - 鎵归噺鍒锋柊鐢ㄦ埛閭
669
+ - `POST /creds/deduplicate-by-email` - 鎸夐偖绠卞幓閲嶅嚟璇?
670
+ - `POST /creds/verify-project/{filename}` - 妫€楠屽嚟璇?Project ID
671
+ - `GET /creds/quota/{filename}` - 鑾峰彇鍑瘉棰濆害淇℃伅锛堜粎 Antigravity锛?
672
+
673
+ **閰嶇疆绠$悊绔偣**
674
+ - `GET /config/get` - 鑾峰彇褰撳墠閰嶇疆
675
+ - `POST /config/save` - 淇濆瓨閰嶇疆
676
+
677
+ **鏃ュ織绠$悊绔偣**
678
+ - `POST /logs/clear` - 娓呯┖鏃ュ織
679
+ - `GET /logs/download` - 涓嬭浇鏃ュ織鏂囦欢
680
+ - `WebSocket /logs/stream` - 瀹炴椂鏃ュ織娴?
681
+
682
+ **鐗堟湰淇℃伅绔偣**
683
+ - `GET /version/info` - 鑾峰彇鐗堟湰淇℃伅锛堝彲閫?`check_update=true` 鍙傛暟妫€鏌ユ洿鏂帮級
684
+
685
+ ### 鑱婂ぉ API 鍔熻兘鐗规€?
686
+
687
+ **澶氭ā鎬佹敮鎸?*
688
+ ```json
689
+ {
690
+ "model": "gemini-2.5-pro",
691
+ "messages": [
692
+ {
693
+ "role": "user",
694
+ "content": [
695
+ {"type": "text", "text": "鎻忚堪杩欏紶鍥剧墖"},
696
+ {
697
+ "type": "image_url",
698
+ "image_url": {
699
+ "url": "data:image/jpeg;base64,/9j/4AAQSkZJRgABA..."
700
+ }
701
+ }
702
+ ]
703
+ }
704
+ ]
705
+ }
706
+ ```
707
+
708
+ **鎬濈淮妯″紡鏀寔**
709
+ ```json
710
+ {
711
+ "model": "gemini-2.5-pro-maxthinking",
712
+ "messages": [
713
+ {"role": "user", "content": "澶嶆潅鏁板闂"}
714
+ ]
715
+ }
716
+ ```
717
+
718
+ 鍝嶅簲灏嗗寘鍚垎绂荤殑鎬濈淮鍐呭锛?
719
+ ```json
720
+ {
721
+ "choices": [{
722
+ "message": {
723
+ "role": "assistant",
724
+ "content": "鏈€缁堢瓟妗?,
725
+ "reasoning_content": "璇︾粏鐨勬€濊€冭繃绋?.."
726
+ }
727
+ }]
728
+ }
729
+ ```
730
+
731
+ **娴佸紡鎶楁埅鏂娇鐢?*
732
+ ```json
733
+ {
734
+ "model": "娴佸紡鎶楁埅鏂?gemini-2.5-pro",
735
+ "messages": [
736
+ {"role": "user", "content": "鍐欎竴绡囬暱鏂囩珷"}
737
+ ],
738
+ "stream": true
739
+ }
740
+ ```
741
+
742
+ **鍏煎鎬фā寮?*
743
+ ```bash
744
+ # 鍚敤鍏煎鎬фā寮?
745
+ export COMPATIBILITY_MODE=true
746
+ ```
747
+ 姝ゆā寮忎笅锛屾墍鏈?`system` 娑堟伅浼氳浆鎹负 `user` 娑堟伅锛屾彁楂樹笌鏌愪簺瀹㈡埛绔殑鍏煎鎬с€?
748
+
749
+ ---
750
+
751
+ ## 馃挰 浜ゆ祦缇?
752
+
753
+ 娆㈣繋鍔犲叆 QQ 缇や氦娴佽璁猴紒
754
+
755
+ **QQ 缇ゅ彿锛?083250744**
756
+
757
+ <img src="docs/qq缇?jpg" width="200" alt="QQ缇や簩缁寸爜">
758
+
759
+ ---
760
+
761
+ ## 璁稿彲璇佷笌鍏嶈矗澹版槑
762
+
763
+ 鏈」鐩粎渚涘涔犲拰鐮旂┒鐢ㄩ€斻€備娇鐢ㄦ湰椤圭洰琛ㄧず鎮ㄥ悓鎰忥細
764
+ - 涓嶅皢鏈」鐩敤浜庝换浣曞晢涓氱敤閫?
765
+ - 鎵挎媴浣跨敤鏈」鐩殑鎵€鏈夐闄╁拰璐d换
766
+ - 閬靛畧鐩稿叧鐨勬湇鍔℃潯娆惧拰娉曞緥娉曡
767
+
768
+ 椤圭洰浣滆€呭鍥犱娇鐢ㄦ湰椤圭洰鑰屼骇鐢熺殑浠讳綍鐩存帴鎴栭棿鎺ユ崯澶变笉鎵挎媴璐d换銆?
config.py ADDED
@@ -0,0 +1,457 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Configuration constants for the Geminicli2api proxy server.
3
+ Centralizes all configuration to avoid duplication across modules.
4
+
5
+ - 启动时加载一次配置到内存
6
+ - 修改配置时调用 reload_config() 重新从数据库加载
7
+ """
8
+
9
+ import os
10
+ from typing import Any, Optional
11
+
12
+ # 全局配置缓存
13
+ _config_cache: dict[str, Any] = {}
14
+ _config_initialized = False
15
+
16
+ # Client Configuration
17
+
18
+ # 需要自动封禁的错误码 (默认值,可通过环境变量或配置覆盖)
19
+ AUTO_BAN_ERROR_CODES = [403]
20
+
21
+ # ====================== 环境变量映射表 ======================
22
+ # 统一维护环境变量名和配置键名的映射关系
23
+ # 格式: "环境变量名": "配置键名"
24
+ ENV_MAPPINGS = {
25
+ "CODE_ASSIST_ENDPOINT": "code_assist_endpoint",
26
+ "CREDENTIALS_DIR": "credentials_dir",
27
+ "PROXY": "proxy",
28
+ "OAUTH_PROXY_URL": "oauth_proxy_url",
29
+ "GOOGLEAPIS_PROXY_URL": "googleapis_proxy_url",
30
+ "RESOURCE_MANAGER_API_URL": "resource_manager_api_url",
31
+ "SERVICE_USAGE_API_URL": "service_usage_api_url",
32
+ "AUTO_BAN": "auto_ban_enabled",
33
+ "AUTO_BAN_ERROR_CODES": "auto_ban_error_codes",
34
+ "RETRY_429_MAX_RETRIES": "retry_429_max_retries",
35
+ "RETRY_429_ENABLED": "retry_429_enabled",
36
+ "RETRY_429_INTERVAL": "retry_429_interval",
37
+ "ANTI_TRUNCATION_MAX_ATTEMPTS": "anti_truncation_max_attempts",
38
+ "COMPATIBILITY_MODE": "compatibility_mode_enabled",
39
+ "RETURN_THOUGHTS_TO_FRONTEND": "return_thoughts_to_frontend",
40
+ "ANTIGRAVITY_STREAM2NOSTREAM": "antigravity_stream2nostream",
41
+ "HOST": "host",
42
+ "PORT": "port",
43
+ "API_PASSWORD": "api_password",
44
+ "PANEL_PASSWORD": "panel_password",
45
+ "PASSWORD": "password",
46
+ "KEEPALIVE_URL": "keepalive_url",
47
+ "KEEPALIVE_INTERVAL": "keepalive_interval",
48
+ }
49
+
50
+
51
+ # ====================== 配置系统 ======================
52
+
53
+ async def init_config():
54
+ """初始化配置缓存(启动时调用一次)"""
55
+ global _config_cache, _config_initialized
56
+
57
+ if _config_initialized:
58
+ return
59
+
60
+ try:
61
+ from src.storage_adapter import get_storage_adapter
62
+ storage_adapter = await get_storage_adapter()
63
+ _config_cache = await storage_adapter.get_all_config()
64
+ _config_initialized = True
65
+ except Exception:
66
+ # 初始化失败时使用空缓存
67
+ _config_cache = {}
68
+ _config_initialized = True
69
+
70
+
71
+ async def reload_config():
72
+ """重新加载配置(修改配置后调用)"""
73
+ global _config_cache, _config_initialized
74
+
75
+ try:
76
+ from src.storage_adapter import get_storage_adapter
77
+ storage_adapter = await get_storage_adapter()
78
+
79
+ # 如果后端支持 reload_config_cache,调用它
80
+ if hasattr(storage_adapter._backend, 'reload_config_cache'):
81
+ await storage_adapter._backend.reload_config_cache()
82
+
83
+ # 重新加载配置缓存
84
+ _config_cache = await storage_adapter.get_all_config()
85
+ _config_initialized = True
86
+ except Exception:
87
+ pass
88
+
89
+
90
+ def _get_cached_config(key: str, default: Any = None) -> Any:
91
+ """从内存缓存获取配置(同步)"""
92
+ return _config_cache.get(key, default)
93
+
94
+
95
+ async def get_config_value(key: str, default: Any = None, env_var: Optional[str] = None) -> Any:
96
+ """Get configuration value with priority: ENV > Storage > default."""
97
+ # 确保配置已初始化
98
+ if not _config_initialized:
99
+ await init_config()
100
+
101
+ # Priority 1: Environment variable
102
+ if env_var and os.getenv(env_var):
103
+ return os.getenv(env_var)
104
+
105
+ # Priority 2: Memory cache
106
+ value = _get_cached_config(key)
107
+ if value is not None:
108
+ return value
109
+
110
+ return default
111
+
112
+
113
+ # Configuration getters - all async
114
+ async def get_proxy_config():
115
+ """Get proxy configuration."""
116
+ proxy_url = await get_config_value("proxy", env_var="PROXY")
117
+ return proxy_url if proxy_url else None
118
+
119
+
120
+ async def get_auto_ban_enabled() -> bool:
121
+ """Get auto ban enabled setting."""
122
+ env_value = os.getenv("AUTO_BAN")
123
+ if env_value:
124
+ return env_value.lower() in ("true", "1", "yes", "on")
125
+
126
+ return bool(await get_config_value("auto_ban_enabled", False))
127
+
128
+
129
+ async def get_auto_ban_error_codes() -> list:
130
+ """
131
+ Get auto ban error codes.
132
+
133
+ Environment variable: AUTO_BAN_ERROR_CODES (comma-separated, e.g., "400,403")
134
+ Database config key: auto_ban_error_codes
135
+ Default: [400, 403]
136
+ """
137
+ env_value = os.getenv("AUTO_BAN_ERROR_CODES")
138
+ if env_value:
139
+ try:
140
+ return [int(code.strip()) for code in env_value.split(",") if code.strip()]
141
+ except ValueError:
142
+ pass
143
+
144
+ codes = await get_config_value("auto_ban_error_codes")
145
+ if codes and isinstance(codes, list):
146
+ return codes
147
+ return AUTO_BAN_ERROR_CODES
148
+
149
+
150
+ async def get_retry_429_max_retries() -> int:
151
+ """Get max retries for 429 errors."""
152
+ env_value = os.getenv("RETRY_429_MAX_RETRIES")
153
+ if env_value:
154
+ try:
155
+ return int(env_value)
156
+ except ValueError:
157
+ pass
158
+
159
+ return int(await get_config_value("retry_429_max_retries", 5))
160
+
161
+
162
+ async def get_retry_429_enabled() -> bool:
163
+ """Get 429 retry enabled setting."""
164
+ env_value = os.getenv("RETRY_429_ENABLED")
165
+ if env_value:
166
+ return env_value.lower() in ("true", "1", "yes", "on")
167
+
168
+ return bool(await get_config_value("retry_429_enabled", True))
169
+
170
+
171
+ async def get_retry_429_interval() -> float:
172
+ """Get 429 retry interval in seconds."""
173
+ env_value = os.getenv("RETRY_429_INTERVAL")
174
+ if env_value:
175
+ try:
176
+ return float(env_value)
177
+ except ValueError:
178
+ pass
179
+
180
+ return float(await get_config_value("retry_429_interval", 1))
181
+
182
+
183
+ async def get_anti_truncation_max_attempts() -> int:
184
+ """
185
+ Get maximum attempts for anti-truncation continuation.
186
+
187
+ Environment variable: ANTI_TRUNCATION_MAX_ATTEMPTS
188
+ Database config key: anti_truncation_max_attempts
189
+ Default: 3
190
+ """
191
+ env_value = os.getenv("ANTI_TRUNCATION_MAX_ATTEMPTS")
192
+ if env_value:
193
+ try:
194
+ return int(env_value)
195
+ except ValueError:
196
+ pass
197
+
198
+ return int(await get_config_value("anti_truncation_max_attempts", 3))
199
+
200
+
201
+ # Server Configuration
202
+ async def get_server_host() -> str:
203
+ """
204
+ Get server host setting.
205
+
206
+ Environment variable: HOST
207
+ Database config key: host
208
+ Default: 0.0.0.0
209
+ """
210
+ return str(await get_config_value("host", "0.0.0.0", "HOST"))
211
+
212
+
213
+ async def get_server_port() -> int:
214
+ """
215
+ Get server port setting.
216
+
217
+ Environment variable: PORT
218
+ Database config key: port
219
+ Default: 7861
220
+ """
221
+ env_value = os.getenv("PORT")
222
+ if env_value:
223
+ try:
224
+ return int(env_value)
225
+ except ValueError:
226
+ pass
227
+
228
+ return int(await get_config_value("port", 7861))
229
+
230
+
231
+ async def get_api_password() -> str:
232
+ """
233
+ Get API password setting for chat endpoints.
234
+
235
+ Environment variable: API_PASSWORD
236
+ Database config key: api_password
237
+ Default: Uses PASSWORD env var for compatibility, otherwise 'pwd'
238
+ """
239
+ # 优先使用 API_PASSWORD,如果没有则使用通用 PASSWORD 保证兼容性
240
+ api_password = await get_config_value("api_password", None, "API_PASSWORD")
241
+ if api_password is not None:
242
+ return str(api_password)
243
+
244
+ # 兼容性:使用通用密码
245
+ return str(await get_config_value("password", "pwd", "PASSWORD"))
246
+
247
+
248
+ async def get_panel_password() -> str:
249
+ """
250
+ Get panel password setting for web interface.
251
+
252
+ Environment variable: PANEL_PASSWORD
253
+ Database config key: panel_password
254
+ Default: Uses PASSWORD env var for compatibility, otherwise 'pwd'
255
+ """
256
+ # 优先使用 PANEL_PASSWORD,如果没有则使用通用 PASSWORD 保证兼容性
257
+ panel_password = await get_config_value("panel_password", None, "PANEL_PASSWORD")
258
+ if panel_password is not None:
259
+ return str(panel_password)
260
+
261
+ # 兼容性:使用通用密码
262
+ return str(await get_config_value("password", "pwd", "PASSWORD"))
263
+
264
+
265
+ async def get_server_password() -> str:
266
+ """
267
+ Get server password setting (deprecated, use get_api_password or get_panel_password).
268
+
269
+ Environment variable: PASSWORD
270
+ Database config key: password
271
+ Default: pwd
272
+ """
273
+ return str(await get_config_value("password", "pwd", "PASSWORD"))
274
+
275
+
276
+ async def get_credentials_dir() -> str:
277
+ """
278
+ Get credentials directory setting.
279
+
280
+ Environment variable: CREDENTIALS_DIR
281
+ Database config key: credentials_dir
282
+ Default: ./creds
283
+ """
284
+ return str(await get_config_value("credentials_dir", "./creds", "CREDENTIALS_DIR"))
285
+
286
+
287
+ async def get_code_assist_endpoint() -> str:
288
+ """
289
+ Get Code Assist endpoint setting.
290
+
291
+ Environment variable: CODE_ASSIST_ENDPOINT
292
+ Database config key: code_assist_endpoint
293
+ Default: https://cloudcode-pa.googleapis.com
294
+ """
295
+ return str(
296
+ await get_config_value(
297
+ "code_assist_endpoint", "https://cloudcode-pa.googleapis.com", "CODE_ASSIST_ENDPOINT"
298
+ )
299
+ )
300
+
301
+
302
+ async def get_compatibility_mode_enabled() -> bool:
303
+ """
304
+ Get compatibility mode setting.
305
+
306
+ 兼容性模式:启用后所有system消息全部转换成user,停用system_instructions。
307
+ 该选项可能会降低模型理解能力,但是能避免流式空回的情况。
308
+
309
+ Environment variable: COMPATIBILITY_MODE
310
+ Database config key: compatibility_mode_enabled
311
+ Default: False
312
+ """
313
+ env_value = os.getenv("COMPATIBILITY_MODE")
314
+ if env_value:
315
+ return env_value.lower() in ("true", "1", "yes", "on")
316
+
317
+ return bool(await get_config_value("compatibility_mode_enabled", False))
318
+
319
+
320
+ async def get_return_thoughts_to_frontend() -> bool:
321
+ """
322
+ Get return thoughts to frontend setting.
323
+
324
+ 控制是否将思维链返回到前端。
325
+ 启用后,思维链会在响应中返回;禁用后,思维链会在响应中被过滤掉。
326
+
327
+ Environment variable: RETURN_THOUGHTS_TO_FRONTEND
328
+ Database config key: return_thoughts_to_frontend
329
+ Default: True
330
+ """
331
+ env_value = os.getenv("RETURN_THOUGHTS_TO_FRONTEND")
332
+ if env_value:
333
+ return env_value.lower() in ("true", "1", "yes", "on")
334
+
335
+ return bool(await get_config_value("return_thoughts_to_frontend", True))
336
+
337
+
338
+ async def get_antigravity_stream2nostream() -> bool:
339
+ """
340
+ Get use stream for non-stream setting.
341
+
342
+ 控制antigravity非流式请求是否使用流式API并收集为完整响应。
343
+ 启用后,非流式请求将在后端使用流式API,然后收集所有块后再返回完整响应。
344
+
345
+ Environment variable: ANTIGRAVITY_STREAM2NOSTREAM
346
+ Database config key: antigravity_stream2nostream
347
+ Default: True
348
+ """
349
+ env_value = os.getenv("ANTIGRAVITY_STREAM2NOSTREAM")
350
+ if env_value:
351
+ return env_value.lower() in ("true", "1", "yes", "on")
352
+
353
+ return bool(await get_config_value("antigravity_stream2nostream", True))
354
+
355
+
356
+ async def get_oauth_proxy_url() -> str:
357
+ """
358
+ Get OAuth proxy URL setting.
359
+
360
+ 用于Google OAuth2认证的代理URL。
361
+
362
+ Environment variable: OAUTH_PROXY_URL
363
+ Database config key: oauth_proxy_url
364
+ Default: https://oauth2.googleapis.com
365
+ """
366
+ return str(
367
+ await get_config_value(
368
+ "oauth_proxy_url", "https://oauth2.googleapis.com", "OAUTH_PROXY_URL"
369
+ )
370
+ )
371
+
372
+
373
+ async def get_googleapis_proxy_url() -> str:
374
+ """
375
+ Get Google APIs proxy URL setting.
376
+
377
+ 用于Google APIs调用的代理URL。
378
+
379
+ Environment variable: GOOGLEAPIS_PROXY_URL
380
+ Database config key: googleapis_proxy_url
381
+ Default: https://www.googleapis.com
382
+ """
383
+ return str(
384
+ await get_config_value(
385
+ "googleapis_proxy_url", "https://www.googleapis.com", "GOOGLEAPIS_PROXY_URL"
386
+ )
387
+ )
388
+
389
+
390
+ async def get_resource_manager_api_url() -> str:
391
+ """
392
+ Get Google Cloud Resource Manager API URL setting.
393
+
394
+ 用于Google Cloud Resource Manager API的URL。
395
+
396
+ Environment variable: RESOURCE_MANAGER_API_URL
397
+ Database config key: resource_manager_api_url
398
+ Default: https://cloudresourcemanager.googleapis.com
399
+ """
400
+ return str(
401
+ await get_config_value(
402
+ "resource_manager_api_url",
403
+ "https://cloudresourcemanager.googleapis.com",
404
+ "RESOURCE_MANAGER_API_URL",
405
+ )
406
+ )
407
+
408
+
409
+ async def get_service_usage_api_url() -> str:
410
+ """
411
+ Get Google Cloud Service Usage API URL setting.
412
+
413
+ 用于Google Cloud Service Usage API的URL。
414
+
415
+ Environment variable: SERVICE_USAGE_API_URL
416
+ Database config key: service_usage_api_url
417
+ Default: https://serviceusage.googleapis.com
418
+ """
419
+ return str(
420
+ await get_config_value(
421
+ "service_usage_api_url", "https://serviceusage.googleapis.com", "SERVICE_USAGE_API_URL"
422
+ )
423
+ )
424
+
425
+
426
+ async def get_keepalive_url() -> str:
427
+ """
428
+ Get keep-alive URL setting.
429
+
430
+ 配置后保活服务会定期向该URL发送GET请求。
431
+ 留空表示禁用保活服务。
432
+
433
+ Environment variable: KEEPALIVE_URL
434
+ Database config key: keepalive_url
435
+ Default: "" (disabled)
436
+ """
437
+ return str(await get_config_value("keepalive_url", "", "KEEPALIVE_URL"))
438
+
439
+
440
+ async def get_keepalive_interval() -> int:
441
+ """
442
+ Get keep-alive interval in seconds.
443
+
444
+ 保活请求发送间隔(秒)。
445
+
446
+ Environment variable: KEEPALIVE_INTERVAL
447
+ Database config key: keepalive_interval
448
+ Default: 60
449
+ """
450
+ env_value = os.getenv("KEEPALIVE_INTERVAL")
451
+ if env_value:
452
+ try:
453
+ return int(env_value)
454
+ except ValueError:
455
+ pass
456
+
457
+ return int(await get_config_value("keepalive_interval", 60))
darwin-install.sh ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+ # macOS 安装脚本 (支持 Intel 和 Apple Silicon)
3
+
4
+ # 确保 Homebrew 已安装
5
+ if ! command -v brew &> /dev/null; then
6
+ echo "未检测到 Homebrew,开始安装..."
7
+ /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
8
+
9
+ # 检测 Homebrew 安装路径并设置环境变量
10
+ if [[ -f "/opt/homebrew/bin/brew" ]]; then
11
+ # Apple Silicon Mac
12
+ eval "$(/opt/homebrew/bin/brew shellenv)"
13
+ elif [[ -f "/usr/local/bin/brew" ]]; then
14
+ # Intel Mac
15
+ eval "$(/usr/local/bin/brew shellenv)"
16
+ fi
17
+ fi
18
+
19
+ # 更新 brew 并安装 git
20
+ brew update
21
+ brew install git
22
+
23
+ # 安装 uv (Python 环境管理工具)
24
+ curl -Ls https://astral.sh/uv/install.sh | sh
25
+
26
+ # 确保 uv 在 PATH 中
27
+ export PATH="$HOME/.local/bin:$PATH"
28
+
29
+ # 克隆或进入项目目录
30
+ if [ -f "./web.py" ]; then
31
+ # 已经在目标目录
32
+ :
33
+ elif [ -f "./gcli2api/web.py" ]; then
34
+ cd ./gcli2api
35
+ else
36
+ git clone https://github.com/su-kaka/gcli2api.git
37
+ cd ./gcli2api
38
+ fi
39
+
40
+ # 拉取最新代码
41
+ git pull
42
+
43
+ # 创建并同步虚拟环境
44
+ uv sync
45
+
46
+ # 激活虚拟环境
47
+ if [ -f ".venv/bin/activate" ]; then
48
+ source .venv/bin/activate
49
+ else
50
+ echo "❌ 未找到虚拟环境,请检查 uv 是否安装成功"
51
+ exit 1
52
+ fi
53
+
54
+ # 启动项目
55
+ python3 web.py
docker-compose.yml ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ version: '3.8'
2
+
3
+ services:
4
+ gcli2api:
5
+ image: ghcr.io/su-kaka/gcli2api:latest
6
+ container_name: gcli2api
7
+ restart: unless-stopped
8
+ network_mode: host
9
+ environment:
10
+ # Password configuration (choose one)
11
+ # Option 1: Use common password
12
+ - PASSWORD=${PASSWORD:-pwd}
13
+ # Option 2: Use separate passwords (uncomment if needed)
14
+ # - API_PASSWORD=${API_PASSWORD:-your_api_password}
15
+ # - PANEL_PASSWORD=${PANEL_PASSWORD:-your_panel_password}
16
+
17
+ # Server configuration
18
+ - PORT=${PORT:-7861}
19
+ - HOST=${HOST:-0.0.0.0}
20
+
21
+ # Optional: Google credentials from environment
22
+ # - GOOGLE_CREDENTIALS=${GOOGLE_CREDENTIALS}
23
+
24
+ # Optional: Logging configuration
25
+ # - LOG_LEVEL=${LOG_LEVEL:-info}
26
+
27
+ # Optional: Redis configuration (for distributed storage)
28
+ # - REDIS_URI=${REDIS_URI}
29
+ # - REDIS_DATABASE=${REDIS_DATABASE:-0}
30
+
31
+ # Optional: MongoDB configuration (for distributed storage)
32
+ # - MONGODB_URI=${MONGODB_URI}
33
+ # - MONGODB_DATABASE=${MONGODB_DATABASE:-gcli2api}
34
+
35
+ # Optional: PostgreSQL configuration (for distributed storage)
36
+ # - POSTGRES_DSN=${POSTGRES_DSN}
37
+
38
+ # Optional: Proxy configuration
39
+ # - PROXY=${PROXY}
40
+ volumes:
41
+ - ./data/creds:/app/creds
42
+
43
+ # Example with Redis for distributed storage
44
+ # redis:
45
+ # image: redis:7-alpine
46
+ # container_name: gcli2api-redis
47
+ # restart: unless-stopped
48
+ # ports:
49
+ # - "6379:6379"
50
+ # volumes:
51
+ # - redis_data:/data
52
+ # command: redis-server --appendonly yes
53
+ # healthcheck:
54
+ # test: ["CMD", "redis-cli", "ping"]
55
+ # interval: 10s
56
+ # timeout: 3s
57
+ # retries: 3
58
+
59
+ # Example with MongoDB for distributed storage
60
+ # mongodb:
61
+ # image: mongo:7
62
+ # container_name: gcli2api-mongodb
63
+ # restart: unless-stopped
64
+ # environment:
65
+ # MONGO_INITDB_ROOT_USERNAME: ${MONGO_USER:-admin}
66
+ # MONGO_INITDB_ROOT_PASSWORD: ${MONGO_PASSWORD:-password}
67
+ # ports:
68
+ # - "27017:27017"
69
+ # volumes:
70
+ # - mongodb_data:/data/db
71
+ # healthcheck:
72
+ # test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"]
73
+ # interval: 10s
74
+ # timeout: 5s
75
+ # retries: 3
76
+
77
+ #volumes:
78
+ # redis_data:
79
+ # mongodb_data:
docs/README_EN.md ADDED
@@ -0,0 +1,760 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # GeminiCLI to API
2
+
3
+ **Convert GeminiCLI and Antigravity to OpenAI, GEMINI, and Claude API Compatible Interfaces**
4
+
5
+ [![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
+ ## ⚠️ License Declaration
18
+
19
+ **This project is licensed under the Cooperative Non-Commercial License (CNC-1.0)**
20
+
21
+ This is a strict anti-commercial open source license. Please refer to the [LICENSE](../LICENSE) file for details.
22
+
23
+ ### ✅ Permitted Uses:
24
+ - Personal learning, research, and educational purposes
25
+ - Non-profit organization use
26
+ - Open source project integration (must comply with the same license)
27
+ - Academic research and publication
28
+
29
+ ### ❌ Prohibited Uses:
30
+ - Any form of commercial use
31
+ - Enterprise use with annual revenue exceeding $1 million
32
+ - Venture capital-backed or publicly traded companies
33
+ - Providing paid services or products
34
+ - Commercial competitive use
35
+
36
+ ## Core Features
37
+
38
+ ### 🔄 API Endpoints and Format Support
39
+
40
+ **Multi-endpoint Multi-format Support**
41
+ - **OpenAI Compatible Endpoints**: `/v1/chat/completions` and `/v1/models`
42
+ - Supports standard OpenAI format (messages structure)
43
+ - Supports Gemini native format (contents structure)
44
+ - Automatic format detection and conversion, no manual switching required
45
+ - Supports multimodal input (text + images)
46
+ - **Gemini Native Endpoints**: `/v1/models/{model}:generateContent` and `streamGenerateContent`
47
+ - Supports complete Gemini native API specifications
48
+ - Multiple authentication methods: Bearer Token, x-goog-api-key header, URL parameter key
49
+ - **Claude Format Compatibility**: Full support for Claude API format
50
+ - Endpoint: `/v1/messages` (follows Claude API specification)
51
+ - Supports Claude standard messages format
52
+ - Supports system parameter and Claude-specific features
53
+ - Automatically converts to backend-supported format
54
+ - **Antigravity API Support**: Supports OpenAI, Gemini, and Claude formats
55
+ - OpenAI format endpoint: `/antigravity/v1/chat/completions`
56
+ - Gemini format endpoint: `/antigravity/v1/models/{model}:generateContent` and `streamGenerateContent`
57
+ - Claude format endpoint: `/antigravity/v1/messages`
58
+ - Supports all Antigravity models (Claude, Gemini, etc.)
59
+ - Automatic model name mapping and thinking mode detection
60
+
61
+ ### 🔐 Authentication and Security Management
62
+
63
+ **Flexible Password Management**
64
+ - **Separate Password Support**: API password (chat endpoints) and control panel password can be set independently
65
+ - **Multiple Authentication Methods**: Supports Authorization Bearer, x-goog-api-key header, URL parameters, etc.
66
+ - **JWT Token Authentication**: Control panel supports JWT token authentication
67
+ - **User Email Retrieval**: Automatically retrieves and displays Google account email addresses
68
+
69
+ ### 📊 Intelligent Credential Management System
70
+
71
+ **Advanced Credential Management**
72
+ - Multiple Google OAuth credential automatic rotation
73
+ - Enhanced stability through redundant authentication
74
+ - Load balancing and concurrent request support
75
+ - Automatic failure detection and credential disabling
76
+ - Credential usage statistics and quota management
77
+ - Support for manual enable/disable credential files
78
+ - Batch credential file operations (enable, disable, delete)
79
+
80
+ **Credential Status Monitoring**
81
+ - Real-time credential health checks
82
+ - Error code tracking (429, 403, 500, etc.)
83
+ - Automatic banning mechanism (configurable)
84
+
85
+ ### 🌊 Streaming and Response Processing
86
+
87
+ **Multiple Streaming Support**
88
+ - True real-time streaming responses
89
+ - Fake streaming mode (for compatibility)
90
+ - Streaming anti-truncation feature (prevents answer truncation)
91
+ - Asynchronous task management and timeout handling
92
+
93
+ **Response Optimization**
94
+ - Thinking chain content separation
95
+ - Reasoning process (reasoning_content) handling
96
+ - Multi-turn conversation context management
97
+ - Compatibility mode (converts system messages to user messages)
98
+
99
+ ### 🎛️ Web Management Console
100
+
101
+ **Full-featured Web Interface**
102
+ - OAuth authentication flow management (supports GCLI and Antigravity dual modes)
103
+ - Credential file upload, download, and management
104
+ - Real-time log viewing (WebSocket)
105
+ - System configuration management
106
+ - Usage statistics and monitoring dashboard
107
+ - Mobile-friendly interface
108
+
109
+ **Batch Operation Support**
110
+ - ZIP file batch credential upload (GCLI and Antigravity)
111
+ - Batch enable/disable/delete credentials
112
+ - Batch user email retrieval
113
+ - Batch configuration management
114
+ - Unified batch upload interface for all credential types
115
+
116
+ ### 📈 Usage Monitoring
117
+
118
+ **Real-time Monitoring**
119
+ - WebSocket real-time log streams
120
+ - System status monitoring
121
+ - Credential health status
122
+
123
+ ### 🔧 Advanced Configuration and Customization
124
+
125
+ **Network and Proxy Configuration**
126
+ - HTTP/HTTPS proxy support
127
+ - Proxy endpoint configuration (OAuth, Google APIs, metadata service)
128
+ - Timeout and retry configuration
129
+ - Network error handling and recovery
130
+
131
+ **Performance and Stability Configuration**
132
+ - 429 error automatic retry (configurable interval and attempts)
133
+ - Anti-truncation maximum retry attempts
134
+
135
+ **Logging and Debugging**
136
+ - Multi-level logging system (DEBUG, INFO, WARNING, ERROR)
137
+ - Log file management
138
+ - Real-time log streams
139
+ - Log download and clearing
140
+
141
+ ### 🔄 Environment Variables and Configuration Management
142
+
143
+ **Flexible Configuration Methods**
144
+ - Environment variable configuration
145
+ - Hot configuration updates (partial configuration items)
146
+ - Configuration locking (environment variable priority)
147
+
148
+ ## Supported Models
149
+
150
+ All models have 1M context window capacity. Each credential file provides 1000 request quota.
151
+
152
+ ### 🤖 Base Models
153
+ - `gemini-2.5-pro`
154
+ - `gemini-3-pro-preview`
155
+ - `gemini-3.1-pro-preview`
156
+
157
+ ### 🧠 Thinking Models
158
+ - `gemini-2.5-pro-high`: Thinking mode
159
+ - `gemini-2.5-pro-low`: Low thinking mode
160
+ - Supports custom thinking budget configuration
161
+ - Automatic separation of thinking content and final answers
162
+
163
+ ### 🔍 Search-Enhanced Models
164
+ - `gemini-2.5-pro-search`: Model with integrated search functionality
165
+
166
+ ### 🖼️ Image Generation Models (Antigravity)
167
+ - `gemini-3.1-flash-image`: Base image generation model
168
+ - **Resolution Suffixes**:
169
+ - `-2k`: 2K resolution
170
+ - `-4k`: 4K HD resolution
171
+ - **Aspect Ratio Suffixes**:
172
+ - `-1x1`: Square (avatar)
173
+ - `-16x9`: Landscape (desktop wallpaper)
174
+ - `-9x16`: Portrait (mobile wallpaper)
175
+ - `-21x9`: Ultra-wide (ultrawide monitor)
176
+ - `-4x3`: Traditional display
177
+ - `-3x4`: Portrait poster
178
+ - **Combination Examples**:
179
+ - `gemini-3.1-flash-image-4k-16x9`: 4K landscape
180
+ - `gemini-3.1-flash-image-2k-9x16`: 2K portrait
181
+ - When no ratio is specified, the API automatically decides the aspect ratio
182
+
183
+ ### 🌊 Special Feature Variants
184
+ - **Fake Streaming Mode**: Add `-假流式` suffix to any model name
185
+ - Example: `gemini-2.5-pro-假流式`
186
+ - For scenarios requiring streaming responses but server doesn't support true streaming
187
+ - **Streaming Anti-truncation Mode**: Add `流式抗截断/` prefix to model name
188
+ - Example: `流式抗截断/gemini-2.5-pro`
189
+ - Automatically detects response truncation and retries to ensure complete answers
190
+
191
+ ### 🔧 Automatic Model Feature Detection
192
+ - System automatically recognizes feature identifiers in model names
193
+ - Transparently handles feature mode transitions
194
+ - Supports feature combination usage
195
+
196
+
197
+ ---
198
+
199
+ ## Installation Guide
200
+
201
+ ### Termux Environment
202
+
203
+ **Initial Installation**
204
+ ```bash
205
+ curl -o termux-install.sh "https://raw.githubusercontent.com/su-kaka/gcli2api/refs/heads/master/termux-install.sh" && chmod +x termux-install.sh && ./termux-install.sh
206
+ ```
207
+
208
+ **Restart Service**
209
+ ```bash
210
+ cd gcli2api
211
+ bash termux-start.sh
212
+ ```
213
+
214
+ ### Windows Environment
215
+
216
+ **Initial Installation**
217
+ ```powershell
218
+ iex (iwr "https://raw.githubusercontent.com/su-kaka/gcli2api/refs/heads/master/install.ps1" -UseBasicParsing).Content
219
+ ```
220
+
221
+ **Restart Service**
222
+ Double-click to execute `start.bat`
223
+
224
+ ### Linux Environment
225
+
226
+ **Initial Installation**
227
+ ```bash
228
+ curl -o install.sh "https://raw.githubusercontent.com/su-kaka/gcli2api/refs/heads/master/install.sh" && chmod +x install.sh && ./install.sh
229
+ ```
230
+
231
+ **Restart Service**
232
+ ```bash
233
+ cd gcli2api
234
+ bash start.sh
235
+ ```
236
+
237
+ ### macOS Environment
238
+
239
+ **Initial Installation**
240
+ ```bash
241
+ curl -o darwin-install.sh "https://raw.githubusercontent.com/su-kaka/gcli2api/refs/heads/master/darwin-install.sh" && chmod +x darwin-install.sh && ./darwin-install.sh
242
+ ```
243
+
244
+ **Restart Service**
245
+ ```bash
246
+ cd gcli2api
247
+ bash start.sh
248
+ ```
249
+
250
+ ### Docker Environment
251
+
252
+ **Docker Run Command**
253
+ ```bash
254
+ # Using universal password
255
+ docker run -d --name gcli2api --network host -e PASSWORD=pwd -e PORT=7861 -v $(pwd)/data/creds:/app/creds ghcr.io/su-kaka/gcli2api:latest
256
+
257
+ # Using separate passwords
258
+ docker run -d --name gcli2api --network host -e API_PASSWORD=api_pwd -e PANEL_PASSWORD=panel_pwd -e PORT=7861 -v $(pwd)/data/creds:/app/creds ghcr.io/su-kaka/gcli2api:latest
259
+ ```
260
+
261
+ **Docker Mac**
262
+ ```bash
263
+ # Using universal password
264
+ docker run -d \
265
+ --name gcli2api \
266
+ -p 7861:7861 \
267
+ -p 8080:8080 \
268
+ -e PASSWORD=pwd \
269
+ -e PORT=7861 \
270
+ -v "$(pwd)/data/creds":/app/creds \
271
+ ghcr.io/su-kaka/gcli2api:latest
272
+ ```
273
+
274
+ ```bash
275
+ # Using separate passwords
276
+ docker run -d \
277
+ --name gcli2api \
278
+ -p 7861:7861 \
279
+ -p 8080:8080 \
280
+ -e API_PASSWORD=api_pwd \
281
+ -e PANEL_PASSWORD=panel_pwd \
282
+ -e PORT=7861 \
283
+ -v $(pwd)/data/creds:/app/creds \
284
+ ghcr.io/su-kaka/gcli2api:latest
285
+ ```
286
+
287
+ **Docker Compose Run Command**
288
+ 1. Save the following content as `docker-compose.yml` file:
289
+ ```yaml
290
+ version: '3.8'
291
+
292
+ services:
293
+ gcli2api:
294
+ image: ghcr.io/su-kaka/gcli2api:latest
295
+ container_name: gcli2api
296
+ restart: unless-stopped
297
+ network_mode: host
298
+ environment:
299
+ # Using universal password (recommended for simple deployment)
300
+ - PASSWORD=pwd
301
+ - PORT=7861
302
+ # Or use separate passwords (recommended for production)
303
+ # - API_PASSWORD=your_api_password
304
+ # - PANEL_PASSWORD=your_panel_password
305
+ volumes:
306
+ - ./data/creds:/app/creds
307
+ healthcheck:
308
+ test: ["CMD-SHELL", "python -c \"import sys, urllib.request, os; port = os.environ.get('PORT', '7861'); req = urllib.request.Request(f'http://localhost:{port}/v1/models', headers={'Authorization': 'Bearer ' + os.environ.get('PASSWORD', 'pwd')}); sys.exit(0 if urllib.request.urlopen(req, timeout=5).getcode() == 200 else 1)\""]
309
+ interval: 30s
310
+ timeout: 10s
311
+ retries: 3
312
+ start_period: 40s
313
+ ```
314
+ 2. Start the service:
315
+ ```bash
316
+ docker-compose up -d
317
+ ```
318
+
319
+ ---
320
+
321
+ ## Configuration Instructions
322
+
323
+ 1. Visit `http://127.0.0.1:7861` (default port, modifiable via PORT environment variable)
324
+ 2. Complete OAuth authentication flow (default password: `pwd`, modifiable via environment variables)
325
+ - **GCLI Mode**: For obtaining Google Cloud Gemini API credentials
326
+ - **Antigravity Mode**: For obtaining Google Antigravity API credentials
327
+ 3. Configure client:
328
+
329
+ **OpenAI Compatible Client:**
330
+ - **Endpoint Address**: `http://127.0.0.1:7861/v1`
331
+ - **API Key**: `pwd` (default value, modifiable via API_PASSWORD or PASSWORD environment variables)
332
+
333
+ **Gemini Native Client:**
334
+ - **Endpoint Address**: `http://127.0.0.1:7861`
335
+ - **Authentication Methods**:
336
+ - `Authorization: Bearer your_api_password`
337
+ - `x-goog-api-key: your_api_password`
338
+ - URL parameter: `?key=your_api_password`
339
+
340
+ ### 🌟 Dual Authentication Mode Support
341
+
342
+ **GCLI Authentication Mode**
343
+ - Standard Google Cloud Gemini API authentication
344
+ - Supports OAuth2.0 authentication flow
345
+ - Automatically enables required Google Cloud APIs
346
+
347
+ **Antigravity Authentication Mode**
348
+ - Dedicated authentication for Google Antigravity API
349
+ - Independent credential management system
350
+ - Supports batch upload and management
351
+ - Completely isolated from GCLI credentials
352
+
353
+ **Unified Management Interface**
354
+ - Manage both credential types in the "Batch Upload" tab
355
+ - Upper section: GCLI credential batch upload (blue theme)
356
+ - Lower section: Antigravity credential batch upload (green theme)
357
+ - Separate credential management tabs for each type
358
+
359
+ ## 💾 Data Storage Mode
360
+
361
+ ### 🌟 Storage Backend Support
362
+
363
+ gcli2api supports two storage backends: **Local SQLite (Default)** and **MongoDB (Cloud Distributed Storage)**
364
+
365
+ ### 📁 Local SQLite Storage (Default)
366
+
367
+ **Default Storage Method**
368
+ - No configuration required, works out of the box
369
+ - Data is stored in a local SQLite database
370
+ - Suitable for single-machine deployment and personal use
371
+ - Automatically creates and manages database files
372
+
373
+ ### 🍃 MongoDB Cloud Storage Mode
374
+
375
+ **Cloud Distributed Storage Solution**
376
+
377
+ When multi-instance deployment or cloud storage is needed, MongoDB storage mode can be enabled.
378
+
379
+ ### ⚙️ Enable MongoDB Mode
380
+
381
+ **Step 1: Configure MongoDB Connection**
382
+ ```bash
383
+ # Local MongoDB
384
+ export MONGODB_URI="mongodb://localhost:27017"
385
+
386
+ # MongoDB Atlas cloud service
387
+ export MONGODB_URI="mongodb+srv://username:password@cluster.mongodb.net"
388
+
389
+ # MongoDB with authentication
390
+ export MONGODB_URI="mongodb://admin:password@localhost:27017/admin"
391
+
392
+ # Optional: Custom database name (default: gcli2api)
393
+ export MONGODB_DATABASE="my_gcli_db"
394
+ ```
395
+
396
+ **Step 2: Start Application**
397
+ ```bash
398
+ # Application will automatically detect MongoDB configuration and use MongoDB storage
399
+ python web.py
400
+ ```
401
+
402
+ **Docker Environment using MongoDB**
403
+ ```bash
404
+ # Single MongoDB deployment
405
+ docker run -d --name gcli2api \
406
+ -e MONGODB_URI="mongodb://mongodb:27017" \
407
+ -e API_PASSWORD=your_password \
408
+ --network your_network \
409
+ ghcr.io/su-kaka/gcli2api:latest
410
+
411
+ # Using MongoDB Atlas
412
+ docker run -d --name gcli2api \
413
+ -e MONGODB_URI="mongodb+srv://user:pass@cluster.mongodb.net/gcli2api" \
414
+ -e API_PASSWORD=your_password \
415
+ -p 7861:7861 \
416
+ ghcr.io/su-kaka/gcli2api:latest
417
+ ```
418
+
419
+ **Docker Compose Example**
420
+ ```yaml
421
+ version: '3.8'
422
+
423
+ services:
424
+ mongodb:
425
+ image: mongo:7
426
+ container_name: gcli2api-mongodb
427
+ restart: unless-stopped
428
+ environment:
429
+ MONGO_INITDB_ROOT_USERNAME: admin
430
+ MONGO_INITDB_ROOT_PASSWORD: password123
431
+ volumes:
432
+ - mongodb_data:/data/db
433
+ ports:
434
+ - "27017:27017"
435
+
436
+ gcli2api:
437
+ image: ghcr.io/su-kaka/gcli2api:latest
438
+ container_name: gcli2api
439
+ restart: unless-stopped
440
+ depends_on:
441
+ - mongodb
442
+ environment:
443
+ - MONGODB_URI=mongodb://admin:password123@mongodb:27017/admin
444
+ - MONGODB_DATABASE=gcli2api
445
+ - API_PASSWORD=your_api_password
446
+ - PORT=7861
447
+ ports:
448
+ - "7861:7861"
449
+
450
+ volumes:
451
+ mongodb_data:
452
+ ```
453
+
454
+
455
+ ### 🔧 Advanced Configuration
456
+
457
+ **MongoDB Connection Optimization**
458
+ ```bash
459
+ # Connection pool and timeout configuration
460
+ export MONGODB_URI="mongodb://localhost:27017?maxPoolSize=10&serverSelectionTimeoutMS=5000"
461
+
462
+ # Replica set configuration
463
+ export MONGODB_URI="mongodb://host1:27017,host2:27017,host3:27017/gcli2api?replicaSet=myReplicaSet"
464
+
465
+ # Read-write separation configuration
466
+ export MONGODB_URI="mongodb://localhost:27017/gcli2api?readPreference=secondaryPreferred"
467
+ ```
468
+
469
+ ### Environment Variable Configuration
470
+
471
+ **Basic Configuration**
472
+ - `PORT`: Service port (default: 7861)
473
+ - `HOST`: Server listen address (default: 0.0.0.0)
474
+
475
+ **Password Configuration**
476
+ - `API_PASSWORD`: Chat API access password (default: inherits PASSWORD or pwd)
477
+ - `PANEL_PASSWORD`: Control panel access password (default: inherits PASSWORD or pwd)
478
+ - `PASSWORD`: Universal password, overrides the above two when set (default: pwd)
479
+
480
+ **Performance and Stability Configuration**
481
+ - `RETRY_429_ENABLED`: Enable 429 error automatic retry (default: true)
482
+ - `RETRY_429_MAX_RETRIES`: Maximum retry attempts for 429 errors (default: 3)
483
+ - `RETRY_429_INTERVAL`: Retry interval for 429 errors, in seconds (default: 1.0)
484
+ - `ANTI_TRUNCATION_MAX_ATTEMPTS`: Maximum retry attempts for anti-truncation (default: 3)
485
+
486
+ **Network and Proxy Configuration**
487
+ - `PROXY`: HTTP/HTTPS proxy address (format: `http://host:port`)
488
+ - `OAUTH_PROXY_URL`: OAuth authentication proxy endpoint
489
+ - `GOOGLEAPIS_PROXY_URL`: Google APIs proxy endpoint
490
+ - `METADATA_SERVICE_URL`: Metadata service proxy endpoint
491
+
492
+ **Automation Configuration**
493
+ - `AUTO_BAN`: Enable automatic credential banning (default: true)
494
+ - `AUTO_LOAD_ENV_CREDS`: Automatically load environment variable credentials at startup (default: false)
495
+
496
+ **Compatibility Configuration**
497
+ - `COMPATIBILITY_MODE`: Enable compatibility mode, converts system messages to user messages (default: false)
498
+
499
+ **Logging Configuration**
500
+ - `LOG_LEVEL`: Log level (DEBUG/INFO/WARNING/ERROR, default: INFO)
501
+ - `LOG_FILE`: Log file path (default: log.txt)
502
+
503
+ **Storage Configuration**
504
+
505
+ **SQLite Configuration (Default)**
506
+ - No configuration required, automatically uses local SQLite database
507
+ - Database files are automatically created in the project directory
508
+
509
+ **MongoDB Configuration (Optional Cloud Storage)**
510
+ - `MONGODB_URI`: MongoDB connection string (enables MongoDB mode when set)
511
+ - `MONGODB_DATABASE`: MongoDB database name (default: gcli2api)
512
+
513
+ **Docker Usage Example**
514
+ ```bash
515
+ # Using universal password
516
+ docker run -d --name gcli2api \
517
+ -e PASSWORD=mypassword \
518
+ -e PORT=7861 \
519
+ ghcr.io/su-kaka/gcli2api:latest
520
+
521
+ # Using separate passwords
522
+ docker run -d --name gcli2api \
523
+ -e API_PASSWORD=my_api_password \
524
+ -e PANEL_PASSWORD=my_panel_password \
525
+ -e PORT=7861 \
526
+ ghcr.io/su-kaka/gcli2api:latest
527
+ ```
528
+
529
+ Note: When credential environment variables are set, the system will prioritize using credentials from environment variables and ignore files in the `creds` directory.
530
+
531
+ ### API Usage Methods
532
+
533
+ This service supports multiple complete sets of API endpoints:
534
+
535
+ #### 1. OpenAI Compatible Endpoints (GCLI)
536
+
537
+ **Endpoint:** `/v1/chat/completions`
538
+ **Authentication:** `Authorization: Bearer your_api_password`
539
+
540
+ Supports two request formats with automatic detection and processing:
541
+
542
+ **OpenAI Format:**
543
+ ```json
544
+ {
545
+ "model": "gemini-2.5-pro",
546
+ "messages": [
547
+ {"role": "system", "content": "You are a helpful assistant"},
548
+ {"role": "user", "content": "Hello"}
549
+ ],
550
+ "temperature": 0.7,
551
+ "stream": true
552
+ }
553
+ ```
554
+
555
+ **Gemini Native Format:**
556
+ ```json
557
+ {
558
+ "model": "gemini-2.5-pro",
559
+ "contents": [
560
+ {"role": "user", "parts": [{"text": "Hello"}]}
561
+ ],
562
+ "systemInstruction": {"parts": [{"text": "You are a helpful assistant"}]},
563
+ "generationConfig": {
564
+ "temperature": 0.7
565
+ }
566
+ }
567
+ ```
568
+
569
+ #### 2. Gemini Native Endpoints (GCLI)
570
+
571
+ **Non-streaming Endpoint:** `/v1/models/{model}:generateContent`
572
+ **Streaming Endpoint:** `/v1/models/{model}:streamGenerateContent`
573
+ **Model List:** `/v1/models`
574
+
575
+ **Authentication Methods (choose one):**
576
+ - `Authorization: Bearer your_api_password`
577
+ - `x-goog-api-key: your_api_password`
578
+ - URL parameter: `?key=your_api_password`
579
+
580
+ **Request Examples:**
581
+ ```bash
582
+ # Using x-goog-api-key header
583
+ curl -X POST "http://127.0.0.1:7861/v1/models/gemini-2.5-pro:generateContent" \
584
+ -H "x-goog-api-key: your_api_password" \
585
+ -H "Content-Type: application/json" \
586
+ -d '{
587
+ "contents": [
588
+ {"role": "user", "parts": [{"text": "Hello"}]}
589
+ ]
590
+ }'
591
+
592
+ # Using URL parameter
593
+ curl -X POST "http://127.0.0.1:7861/v1/models/gemini-2.5-pro:streamGenerateContent?key=your_api_password" \
594
+ -H "Content-Type: application/json" \
595
+ -d '{
596
+ "contents": [
597
+ {"role": "user", "parts": [{"text": "Hello"}]}
598
+ ]
599
+ }'
600
+ ```
601
+
602
+ #### 3. Claude API Format Endpoints
603
+
604
+ **Endpoint:** `/v1/messages`
605
+ **Authentication:** `x-api-key: your_api_password` or `Authorization: Bearer your_api_password`
606
+
607
+ **Request Example:**
608
+ ```bash
609
+ curl -X POST "http://127.0.0.1:7861/v1/messages" \
610
+ -H "x-api-key: your_api_password" \
611
+ -H "anthropic-version: 2023-06-01" \
612
+ -H "Content-Type: application/json" \
613
+ -d '{
614
+ "model": "gemini-2.5-pro",
615
+ "max_tokens": 1024,
616
+ "messages": [
617
+ {"role": "user", "content": "Hello, Claude!"}
618
+ ]
619
+ }'
620
+ ```
621
+
622
+ **Support for system parameter:**
623
+ ```json
624
+ {
625
+ "model": "gemini-2.5-pro",
626
+ "max_tokens": 1024,
627
+ "system": "You are a helpful assistant",
628
+ "messages": [
629
+ {"role": "user", "content": "Hello"}
630
+ ]
631
+ }
632
+ ```
633
+
634
+ **Notes:**
635
+ - Fully compatible with Claude API format specification
636
+ - Automatically converts to Gemini format for backend calls
637
+ - Supports all Claude standard parameters
638
+ - Response format follows Claude API specification
639
+
640
+ ## 📋 Complete API Reference
641
+
642
+ ### Web Console API
643
+
644
+ **Authentication Endpoints**
645
+ - `POST /auth/login` - User login
646
+ - `POST /auth/start` - Start OAuth authentication (supports GCLI and Antigravity modes)
647
+ - `POST /auth/callback` - Handle OAuth callback
648
+ - `POST /auth/callback-url` - Complete authentication directly from callback URL
649
+ - `GET /auth/status/{project_id}` - Check authentication status
650
+
651
+ **Credential Management Endpoints** (supports `mode=geminicli` or `mode=antigravity` parameter)
652
+ - `POST /creds/upload` - Batch upload credential files (supports JSON and ZIP)
653
+ - `GET /creds/status` - Get credential status list (supports pagination and filtering)
654
+ - `GET /creds/detail/{filename}` - Get single credential details
655
+ - `POST /creds/action` - Single credential operation (enable/disable/delete)
656
+ - `POST /creds/batch-action` - Batch credential operations
657
+ - `GET /creds/download/{filename}` - Download single credential file
658
+ - `GET /creds/download-all` - Package download all credentials
659
+ - `POST /creds/fetch-email/{filename}` - Get user email
660
+ - `POST /creds/refresh-all-emails` - Batch refresh user emails
661
+ - `POST /creds/deduplicate-by-email` - Deduplicate credentials by email
662
+ - `POST /creds/verify-project/{filename}` - Verify credential Project ID
663
+ - `GET /creds/quota/{filename}` - Get credential quota information (Antigravity only)
664
+
665
+ **Configuration Management Endpoints**
666
+ - `GET /config/get` - Get current configuration
667
+ - `POST /config/save` - Save configuration
668
+
669
+ **Log Management Endpoints**
670
+ - `POST /logs/clear` - Clear logs
671
+ - `GET /logs/download` - Download log file
672
+ - `WebSocket /logs/stream` - Real-time log stream
673
+
674
+ **Version Information Endpoints**
675
+ - `GET /version/info` - Get version information (optional `check_update=true` parameter to check for updates)
676
+
677
+ ### Chat API Features
678
+
679
+ **Multimodal Support**
680
+ ```json
681
+ {
682
+ "model": "gemini-2.5-pro",
683
+ "messages": [
684
+ {
685
+ "role": "user",
686
+ "content": [
687
+ {"type": "text", "text": "Describe this image"},
688
+ {
689
+ "type": "image_url",
690
+ "image_url": {
691
+ "url": "data:image/jpeg;base64,/9j/4AAQSkZJRgABA..."
692
+ }
693
+ }
694
+ ]
695
+ }
696
+ ]
697
+ }
698
+ ```
699
+
700
+ **Thinking Mode Support**
701
+ ```json
702
+ {
703
+ "model": "gemini-2.5-pro-high",
704
+ "messages": [
705
+ {"role": "user", "content": "Complex math problem"}
706
+ ]
707
+ }
708
+ ```
709
+
710
+ Response will include separated thinking content:
711
+ ```json
712
+ {
713
+ "choices": [{
714
+ "message": {
715
+ "role": "assistant",
716
+ "content": "Final answer",
717
+ "reasoning_content": "Detailed thought process..."
718
+ }
719
+ }]
720
+ }
721
+ ```
722
+
723
+ **Streaming Anti-truncation Usage**
724
+ ```json
725
+ {
726
+ "model": "流式抗截断/gemini-2.5-pro",
727
+ "messages": [
728
+ {"role": "user", "content": "Write a long article"}
729
+ ],
730
+ "stream": true
731
+ }
732
+ ```
733
+
734
+ **Compatibility Mode**
735
+ ```bash
736
+ # Enable compatibility mode
737
+ export COMPATIBILITY_MODE=true
738
+ ```
739
+ In this mode, all `system` messages are converted to `user` messages, improving compatibility with certain clients.
740
+
741
+ ---
742
+
743
+ ## 💬 Community
744
+
745
+ Welcome to join the QQ group for discussion!
746
+
747
+ **QQ Group: 1083250744**
748
+
749
+ <img src="qq群.jpg" width="200" alt="QQ Group QR Code">
750
+
751
+ ---
752
+
753
+ ## License and Disclaimer
754
+
755
+ This project is for learning and research purposes only. Using this project indicates that you agree to:
756
+ - Not use this project for any commercial purposes
757
+ - Bear all risks and responsibilities of using this project
758
+ - Comply with relevant terms of service and legal regulations
759
+
760
+ The project authors are not responsible for any direct or indirect losses arising from the use of this project.
docs/README_JA.md ADDED
@@ -0,0 +1,760 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # GeminiCLI to API
2
+
3
+ **GeminiCLIおよびAntigravityをOpenAI、GEMINI、Claude API互換インターフェースに変換**
4
+
5
+ [![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
+ **本プロジェクトはCooperative Non-Commercial License (CNC-1.0) の下でライセンスされています**
20
+
21
+ これは厳格な非商用オープンソースライセンスです。詳細は [LICENSE](../LICENSE) ファイルをご参照ください。
22
+
23
+ ### ✅ 許可される用途:
24
+ - 個人の学習、研究、教育目的
25
+ - 非営利団体での利用
26
+ - オープンソースプロジェクトへの統合(同一ライセンスの遵守が必要)
27
+ - 学術研究および論文発表
28
+
29
+ ### ❌ 禁止される用途:
30
+ - あらゆる形態の商用利用
31
+ - 年間売上が100万ドルを超える企業での利用
32
+ - ベンチャーキャピタルの出資を受けた企業または上場企業
33
+ - 有料サービスまたは製品の提供
34
+ - 商業的な競合利用
35
+
36
+ ## コア機能
37
+
38
+ ### 🔄 APIエンドポイントとフォーマット対応
39
+
40
+ **マルチエンドポイント・マルチフォーマット対応**
41
+ - **OpenAI互換エンドポイント**: `/v1/chat/completions` および `/v1/models`
42
+ - 標準OpenAIフォーマット(messages構造)に対応
43
+ - Geminiネイティブフォーマット(contents構造)に対応
44
+ - フォーマットの自動検出・変換、手動切替不要
45
+ - マルチモーダル入力に対応(テキスト+画像)
46
+ - **Geminiネイティブエンドポイント**: `/v1/models/{model}:generateContent` および `streamGenerateContent`
47
+ - Geminiネイティブ API仕様に完全対応
48
+ - 複数の認証方式: Bearer Token、x-goog-api-keyヘッダー、URLパラメータkey
49
+ - **Claudeフォーマット互換**: Claude APIフォーマットに完全対応
50
+ - エンドポイント: `/v1/messages`(Claude API仕様に準拠)
51
+ - Claude標準messagesフォーマットに対応
52
+ - systemパラメータおよびClaude固有機能に対応
53
+ - バックエンド対応フォーマットへの自動変換
54
+ - **Antigravity API対応**: OpenAI、Gemini、Claudeフォーマットに対応
55
+ - OpenAIフォーマットエンドポイント: `/antigravity/v1/chat/completions`
56
+ - Geminiフォーマットエンドポイント: `/antigravity/v1/models/{model}:generateContent` および `streamGenerateContent`
57
+ - Claudeフォーマットエンドポイント: `/antigravity/v1/messages`
58
+ - 全Antigravityモデルに対応(Claude、Geminiなど)
59
+ - モデル名の自動マッピングおよびThinkingモード検出
60
+
61
+ ### 🔐 認証とセキュリティ管理
62
+
63
+ **柔軟なパスワード管理**
64
+ - **個別パスワード対応**: APIパスワード(チャットエンドポイント)とコントロールパネルパスワードを個別に設定可能
65
+ - **複数の認証方式**: Authorization Bearer、x-goog-api-keyヘッダー、URLパラメータなどに対応
66
+ - **JWTトークン認証**: コントロールパネルはJWTトークン認証に対応
67
+ - **ユーザーメール取得**: Googleアカウントのメールアドレスを自動取得・表示
68
+
69
+ ### 📊 インテリジェントなクレデンシャル管理システム
70
+
71
+ **高度なクレデンシャル管理**
72
+ - 複数のGoogle OAuthクレデンシャルの自動ローテーション
73
+ - 冗長認証による安定性の向上
74
+ - ロードバランシングと同時リクエスト対応
75
+ - 自動障害検出とクレデンシャル無効化
76
+ - クレデンシャル使用統計とクォータ管理
77
+ - クレデンシャルファイルの手動有効化/無効化に対応
78
+ - クレデンシャルファイルの一括操作(有効化、無効化、削除)
79
+
80
+ **クレデンシャルステータス監視**
81
+ - リアルタイムのクレデンシャルヘルスチェック
82
+ - エラーコードの追跡(429、403、500など)
83
+ - 自動BAN機能(設定可能)
84
+
85
+ ### 🌊 ストリーミングとレスポンス処理
86
+
87
+ **複数のストリーミング対応**
88
+ - リアルタイムストリーミングレスポンス
89
+ - 疑似ストリーミングモード(互換性向上用)
90
+ - ストリーミング途切れ防止機能(回答の途切れを防止)
91
+ - 非同期タスク管理とタイムアウト処理
92
+
93
+ **レスポンス最適化**
94
+ - 思考チェーン内容の分離
95
+ - 推論プロセス(reasoning_content)の処理
96
+ - マルチターン会話のコンテキスト管理
97
+ - 互換モード(systemメッセージをuserメッセージに変換)
98
+
99
+ ### 🎛️ Web管理コンソール
100
+
101
+ **フル機能のWebインターフェース**
102
+ - OAuth認証フロー管理(GCLIおよびAntigravityデュアルモード対応)
103
+ - クレデンシャルファイルのアップロード、ダウンロード、管理
104
+ - リアルタイムログ表示(WebSocket)
105
+ - システム設定管理
106
+ - 使用統計と監視ダッシュボード
107
+ - モバイル対応インターフェース
108
+
109
+ **一括操作対応**
110
+ - ZIPファイルによるクレデンシャル一括アップロード(GCLIおよびAntigravity)
111
+ - クレデンシャルの一括有効化/無効化/削除
112
+ - ユーザーメールの一括取得
113
+ - 設定の一括管理
114
+ - 全クレデンシャルタイプ統合一括アップロードインターフェース
115
+
116
+ ### 📈 使用状況モニタリング
117
+
118
+ **リアルタイム監視**
119
+ - WebSocketリアルタイムログストリーム
120
+ - システムステータス監視
121
+ - クレデンシャルヘルスステータス
122
+
123
+ ### 🔧 高度な設定とカスタマイズ
124
+
125
+ **ネットワークとプロキシ設定**
126
+ - HTTP/HTTPSプロキシ対応
127
+ - プロキシエンドポイント設定(OAuth、Google APIs、メタデータサービス)
128
+ - タイムアウトとリトライ設定
129
+ - ネットワークエラー処理とリカバリ
130
+
131
+ **パフォーマンスと安定性の設定**
132
+ - 429エラーの自動リトライ(間隔と回数を設定可能)
133
+ - 途切れ防止の最大リトライ回数
134
+
135
+ **ログとデバッグ**
136
+ - マルチレベルログシステム(DEBUG、INFO、WARNING、ERROR)
137
+ - ログファイル管理
138
+ - リアルタイムログストリーム
139
+ - ログのダウンロードとクリア
140
+
141
+ ### 🔄 環境変数と設定管理
142
+
143
+ **柔軟な設定方法**
144
+ - 環境変数による設定
145
+ - ホット設定更新(一部設定項目)
146
+ - 設定ロック(環境変数優先)
147
+
148
+ ## 対応モデル
149
+
150
+ 全モデルが100万トークンのコンテキストウィンドウに対応。各クレデンシャルファイルで1000リクエストのクォータを提供。
151
+
152
+ ### 🤖 基本モデル
153
+ - `gemini-2.5-pro`
154
+ - `gemini-3-pro-preview`
155
+ - `gemini-3.1-pro-preview`
156
+
157
+ ### 🧠 Thinkingモデル
158
+ - `gemini-2.5-pro-high`: Thinkingモード
159
+ - `gemini-2.5-pro-low`: 低Thinkingモード
160
+ - カスタムThinkingバジェット設定に対応
161
+ - 思考内容と最終回答の自動分離
162
+
163
+ ### 🔍 検索拡張モデル
164
+ - `gemini-2.5-pro-search`: 検索機能統合モデル
165
+
166
+ ### 🖼️ 画像生成モデル(Antigravity)
167
+ - `gemini-3.1-flash-image`: 基本画像生成モデル
168
+ - **解像度サフィックス**:
169
+ - `-2k`: 2K解像度
170
+ - `-4k`: 4K HD解像度
171
+ - **アスペクト比サフィックス**:
172
+ - `-1x1`: 正方形(アバター)
173
+ - `-16x9`: 横長(デスクトップ壁紙)
174
+ - `-9x16`: 縦長(モバイル壁紙)
175
+ - `-21x9`: ウルトラワイド(ウルトラワイドモニター)
176
+ - `-4x3`: 従来のディスプレイ
177
+ - `-3x4`: 縦型ポスター
178
+ - **組み合わせ例**:
179
+ - `gemini-3.1-flash-image-4k-16x9`: 4K横長
180
+ - `gemini-3.1-flash-image-2k-9x16`: 2K縦長
181
+ - 比率未指定時はAPIが自動的にアスペクト比を決定
182
+
183
+ ### 🌊 特殊機能バリアント
184
+ - **疑似ストリーミングモード**: 任意のモデル名に `-假流式` サフィックスを追加
185
+ - 例: `gemini-2.5-pro-假流式`
186
+ - ストリーミングレスポンスが必要だがサーバーが真のストリーミングに対応していない場合に使用
187
+ - **ストリーミング途切れ防止モード**: モデル名に `流式抗截断/` プレフィックスを追加
188
+ - 例: `流式抗截断/gemini-2.5-pro`
189
+ - レスポンスの途切れを自動検出しリトライして完全な回答を保証
190
+
191
+ ### 🔧 モデル機能の自動検出
192
+ - システムがモデル名内の機能識別子を自動認識
193
+ - 機能モード切替を透過的に処理
194
+ - 機能の組み合わせ使用に対応
195
+
196
+
197
+ ---
198
+
199
+ ## インストールガイド
200
+
201
+ ### Termux環境
202
+
203
+ **初期インストール**
204
+ ```bash
205
+ curl -o termux-install.sh "https://raw.githubusercontent.com/su-kaka/gcli2api/refs/heads/master/termux-install.sh" && chmod +x termux-install.sh && ./termux-install.sh
206
+ ```
207
+
208
+ **サービス再起動**
209
+ ```bash
210
+ cd gcli2api
211
+ bash termux-start.sh
212
+ ```
213
+
214
+ ### Windows環境
215
+
216
+ **初期インストール**
217
+ ```powershell
218
+ iex (iwr "https://raw.githubusercontent.com/su-kaka/gcli2api/refs/heads/master/install.ps1" -UseBasicParsing).Content
219
+ ```
220
+
221
+ **サービス再起動**
222
+ `start.bat` をダブルクリックして実行
223
+
224
+ ### Linux環境
225
+
226
+ **初期インストール**
227
+ ```bash
228
+ curl -o install.sh "https://raw.githubusercontent.com/su-kaka/gcli2api/refs/heads/master/install.sh" && chmod +x install.sh && ./install.sh
229
+ ```
230
+
231
+ **サービス再起動**
232
+ ```bash
233
+ cd gcli2api
234
+ bash start.sh
235
+ ```
236
+
237
+ ### macOS環境
238
+
239
+ **初期インストー���**
240
+ ```bash
241
+ curl -o darwin-install.sh "https://raw.githubusercontent.com/su-kaka/gcli2api/refs/heads/master/darwin-install.sh" && chmod +x darwin-install.sh && ./darwin-install.sh
242
+ ```
243
+
244
+ **サービス再起動**
245
+ ```bash
246
+ cd gcli2api
247
+ bash start.sh
248
+ ```
249
+
250
+ ### Docker環境
251
+
252
+ **Docker Runコマンド**
253
+ ```bash
254
+ # 共通パスワードを使用
255
+ docker run -d --name gcli2api --network host -e PASSWORD=pwd -e PORT=7861 -v $(pwd)/data/creds:/app/creds ghcr.io/su-kaka/gcli2api:latest
256
+
257
+ # 個別パスワードを使用
258
+ docker run -d --name gcli2api --network host -e API_PASSWORD=api_pwd -e PANEL_PASSWORD=panel_pwd -e PORT=7861 -v $(pwd)/data/creds:/app/creds ghcr.io/su-kaka/gcli2api:latest
259
+ ```
260
+
261
+ **Docker Mac**
262
+ ```bash
263
+ # 共通パスワードを使用
264
+ docker run -d \
265
+ --name gcli2api \
266
+ -p 7861:7861 \
267
+ -p 8080:8080 \
268
+ -e PASSWORD=pwd \
269
+ -e PORT=7861 \
270
+ -v "$(pwd)/data/creds":/app/creds \
271
+ ghcr.io/su-kaka/gcli2api:latest
272
+ ```
273
+
274
+ ```bash
275
+ # 個別パスワードを使用
276
+ docker run -d \
277
+ --name gcli2api \
278
+ -p 7861:7861 \
279
+ -p 8080:8080 \
280
+ -e API_PASSWORD=api_pwd \
281
+ -e PANEL_PASSWORD=panel_pwd \
282
+ -e PORT=7861 \
283
+ -v $(pwd)/data/creds:/app/creds \
284
+ ghcr.io/su-kaka/gcli2api:latest
285
+ ```
286
+
287
+ **Docker Compose Runコマンド**
288
+ 1. 以下の内容を `docker-compose.yml` ファイルとして保存:
289
+ ```yaml
290
+ version: '3.8'
291
+
292
+ services:
293
+ gcli2api:
294
+ image: ghcr.io/su-kaka/gcli2api:latest
295
+ container_name: gcli2api
296
+ restart: unless-stopped
297
+ network_mode: host
298
+ environment:
299
+ # 共通パスワードを使用(シンプルなデプロイに推奨)
300
+ - PASSWORD=pwd
301
+ - PORT=7861
302
+ # または個別パスワードを使用(本番環境に推奨)
303
+ # - API_PASSWORD=your_api_password
304
+ # - PANEL_PASSWORD=your_panel_password
305
+ volumes:
306
+ - ./data/creds:/app/creds
307
+ healthcheck:
308
+ test: ["CMD-SHELL", "python -c \"import sys, urllib.request, os; port = os.environ.get('PORT', '7861'); req = urllib.request.Request(f'http://localhost:{port}/v1/models', headers={'Authorization': 'Bearer ' + os.environ.get('PASSWORD', 'pwd')}); sys.exit(0 if urllib.request.urlopen(req, timeout=5).getcode() == 200 else 1)\""]
309
+ interval: 30s
310
+ timeout: 10s
311
+ retries: 3
312
+ start_period: 40s
313
+ ```
314
+ 2. サービスを起動:
315
+ ```bash
316
+ docker-compose up -d
317
+ ```
318
+
319
+ ---
320
+
321
+ ## 設定手順
322
+
323
+ 1. `http://127.0.0.1:7861` にアクセス(デフォルトポート、PORT環境変数で変更可能)
324
+ 2. OAuth認証フローを完了(デフォルトパスワード: `pwd`、環境変数で変更可能)
325
+ - **GCLIモード**: Google Cloud Gemini APIクレデンシャルの取得用
326
+ - **Antigravityモード**: Google Antigravity APIクレデンシャルの取得用
327
+ 3. クライアントを設定:
328
+
329
+ **OpenAI互換クライアント:**
330
+ - **エンドポイントアドレス**: `http://127.0.0.1:7861/v1`
331
+ - **APIキー**: `pwd`(デフォルト値、API_PASSWORDまたはPASSWORD環境変数で変更可能)
332
+
333
+ **Geminiネイティブクライアント:**
334
+ - **エンドポイントアドレス**: `http://127.0.0.1:7861`
335
+ - **認証方式**:
336
+ - `Authorization: Bearer your_api_password`
337
+ - `x-goog-api-key: your_api_password`
338
+ - URLパラメータ: `?key=your_api_password`
339
+
340
+ ### 🌟 デュアル認証モード対応
341
+
342
+ **GCLI認証モード**
343
+ - 標準Google Cloud Gemini API認証
344
+ - OAuth2.0認証フローに対応
345
+ - 必要なGoogle Cloud APIを自動的に有効化
346
+
347
+ **Antigravity認証モード**
348
+ - Google Antigravity API専用認証
349
+ - 独立したクレデンシャル管理システム
350
+ - 一括アップロードと管理に対応
351
+ - GCLIクレデンシャルとは完全に分離
352
+
353
+ **統合管理インターフェース**
354
+ - 「一括アップロード」タブで両方のクレデンシャルタイプを管理
355
+ - 上部セクション: GCLIクレデンシャル一括アップロード(青テーマ)
356
+ - 下部セクション: Antigravityクレデンシャル一括アップロード(緑テーマ)
357
+ - 各タイプ別のクレデンシャル管理タブ
358
+
359
+ ## 💾 データストレージモード
360
+
361
+ ### 🌟 ストレージバックエンド対応
362
+
363
+ gcli2apiは2つのストレージバックエンドに対応: **ローカルSQLite(デフォルト)** と **MongoDB(クラウド分散ストレージ)**
364
+
365
+ ### 📁 ローカルSQLiteストレージ(デフォルト)
366
+
367
+ **デフォルトストレージ方式**
368
+ - 設定不要、すぐに利用可能
369
+ - データはローカルSQLiteデータベースに保存
370
+ - 単一マシンデプロイおよび個人利用に最適
371
+ - データベースファイルの自動作成・管理
372
+
373
+ ### 🍃 MongoDBクラウドストレージモード
374
+
375
+ **クラウド分散ストレージソリューション**
376
+
377
+ マルチインスタンスデプロイやクラウドストレージが必要な場合、MongoDBストレージモードを有効にできます。
378
+
379
+ ### ⚙️ MongoDBモードの有効化
380
+
381
+ **ステップ1: MongoDB接続の設定**
382
+ ```bash
383
+ # ローカルMongoDB
384
+ export MONGODB_URI="mongodb://localhost:27017"
385
+
386
+ # MongoDB Atlasクラウドサービス
387
+ export MONGODB_URI="mongodb+srv://username:password@cluster.mongodb.net"
388
+
389
+ # 認証付きMongoDB
390
+ export MONGODB_URI="mongodb://admin:password@localhost:27017/admin"
391
+
392
+ # オプション: カスタムデータベース名(デフォルト: gcli2api)
393
+ export MONGODB_DATABASE="my_gcli_db"
394
+ ```
395
+
396
+ **ステップ2: アプリケーションの起動**
397
+ ```bash
398
+ # アプリケーションがMongoDB設定を自動検出し、MongoDBストレージを使用します
399
+ python web.py
400
+ ```
401
+
402
+ **Docker環境でのMongoDB使用**
403
+ ```bash
404
+ # 単一MongoDBデプロイ
405
+ docker run -d --name gcli2api \
406
+ -e MONGODB_URI="mongodb://mongodb:27017" \
407
+ -e API_PASSWORD=your_password \
408
+ --network your_network \
409
+ ghcr.io/su-kaka/gcli2api:latest
410
+
411
+ # MongoDB Atlasの使用
412
+ docker run -d --name gcli2api \
413
+ -e MONGODB_URI="mongodb+srv://user:pass@cluster.mongodb.net/gcli2api" \
414
+ -e API_PASSWORD=your_password \
415
+ -p 7861:7861 \
416
+ ghcr.io/su-kaka/gcli2api:latest
417
+ ```
418
+
419
+ **Docker Composeの例**
420
+ ```yaml
421
+ version: '3.8'
422
+
423
+ services:
424
+ mongodb:
425
+ image: mongo:7
426
+ container_name: gcli2api-mongodb
427
+ restart: unless-stopped
428
+ environment:
429
+ MONGO_INITDB_ROOT_USERNAME: admin
430
+ MONGO_INITDB_ROOT_PASSWORD: password123
431
+ volumes:
432
+ - mongodb_data:/data/db
433
+ ports:
434
+ - "27017:27017"
435
+
436
+ gcli2api:
437
+ image: ghcr.io/su-kaka/gcli2api:latest
438
+ container_name: gcli2api
439
+ restart: unless-stopped
440
+ depends_on:
441
+ - mongodb
442
+ environment:
443
+ - MONGODB_URI=mongodb://admin:password123@mongodb:27017/admin
444
+ - MONGODB_DATABASE=gcli2api
445
+ - API_PASSWORD=your_api_password
446
+ - PORT=7861
447
+ ports:
448
+ - "7861:7861"
449
+
450
+ volumes:
451
+ mongodb_data:
452
+ ```
453
+
454
+
455
+ ### 🔧 高度な設定
456
+
457
+ **MongoDB接続の最適化**
458
+ ```bash
459
+ # コネクションプールとタイムアウト設定
460
+ export MONGODB_URI="mongodb://localhost:27017?maxPoolSize=10&serverSelectionTimeoutMS=5000"
461
+
462
+ # レプリカセット設定
463
+ export MONGODB_URI="mongodb://host1:27017,host2:27017,host3:27017/gcli2api?replicaSet=myReplicaSet"
464
+
465
+ # リード・ライト分離設定
466
+ export MONGODB_URI="mongodb://localhost:27017/gcli2api?readPreference=secondaryPreferred"
467
+ ```
468
+
469
+ ### 環境変数設定
470
+
471
+ **基本設定**
472
+ - `PORT`: サービスポート(デフォルト: 7861)
473
+ - `HOST`: サーバーリッスンアドレス(デフォルト: 0.0.0.0)
474
+
475
+ **パスワード設定**
476
+ - `API_PASSWORD`: チャットAPIアクセスパスワード(デフォルト: PASSWORDまたはpwdを継承)
477
+ - `PANEL_PASSWORD`: コントロールパネルアクセスパスワード(デフォルト: PASSWORDまたはpwdを継承)
478
+ - `PASSWORD`: 共通パスワード、設定時に上記2つを上書き(デフォルト: pwd)
479
+
480
+ **パフォーマンスと安定性の設定**
481
+ - `RETRY_429_ENABLED`: 429エラー自動リトライの有効化(デフォルト: true)
482
+ - `RETRY_429_MAX_RETRIES`: 429エラーの最大リトライ回数(デフォルト: 3)
483
+ - `RETRY_429_INTERVAL`: 429エラーのリトライ間隔、秒単位(デフォルト: 1.0)
484
+ - `ANTI_TRUNCATION_MAX_ATTEMPTS`: 途切れ防止の最大リトライ回数(デフォルト: 3)
485
+
486
+ **ネットワークとプロキシ設定**
487
+ - `PROXY`: HTTP/HTTPSプロキシアドレス(形式: `http://host:port`)
488
+ - `OAUTH_PROXY_URL`: OAuth認証プロキシエンドポイント
489
+ - `GOOGLEAPIS_PROXY_URL`: Google APIsプロキシエンドポイント
490
+ - `METADATA_SERVICE_URL`: メタデータサービスプロキシエンドポイント
491
+
492
+ **自動化設定**
493
+ - `AUTO_BAN`: クレデンシャル自動BANの有効化(デフォルト: true)
494
+ - `AUTO_LOAD_ENV_CREDS`: 起動時に環境変数クレデンシャルを自動ロード(デフォルト: false)
495
+
496
+ **互換性設定**
497
+ - `COMPATIBILITY_MODE`: 互換モードの有効化、systemメッセージをuserメッセージに変換(デフォルト: false)
498
+
499
+ **ログ設定**
500
+ - `LOG_LEVEL`: ログレベル(DEBUG/INFO/WARNING/ERROR、デフォルト: INFO)
501
+ - `LOG_FILE`: ログファイルパス(デフォルト: log.txt)
502
+
503
+ **ストレージ設定**
504
+
505
+ **SQLite設定(デフォルト)**
506
+ - 設定不要、自動的にローカルSQLiteデータベースを使用
507
+ - データベースファイルはプロジェクトディレクトリに自動作成
508
+
509
+ **MongoDB設定(オプションのクラウドストレージ)**
510
+ - `MONGODB_URI`: MongoDB接続文字列(設定時にMongoDBモードを有効化)
511
+ - `MONGODB_DATABASE`: MongoDBデータベース名(デフォルト: gcli2api)
512
+
513
+ **Docker使用例**
514
+ ```bash
515
+ # 共通パスワードを使用
516
+ docker run -d --name gcli2api \
517
+ -e PASSWORD=mypassword \
518
+ -e PORT=7861 \
519
+ ghcr.io/su-kaka/gcli2api:latest
520
+
521
+ # 個別パスワードを使用
522
+ docker run -d --name gcli2api \
523
+ -e API_PASSWORD=my_api_password \
524
+ -e PANEL_PASSWORD=my_panel_password \
525
+ -e PORT=7861 \
526
+ ghcr.io/su-kaka/gcli2api:latest
527
+ ```
528
+
529
+ 注意: クレデンシャル環境変数が設定されている場合、システムは環境変数のクレデンシャルを優先的に使用し、`creds` ディレクトリ内のファイルを無視します。
530
+
531
+ ### API使用方法
532
+
533
+ 本サービスは複数の完全なAPIエンドポイントセットに対応しています:
534
+
535
+ #### 1. OpenAI互換エンドポイント(GCLI)
536
+
537
+ **エンドポイント:** `/v1/chat/completions`
538
+ **認証:** `Authorization: Bearer your_api_password`
539
+
540
+ 2つのリクエストフォーマットに対応し、自動検出・処理を行います:
541
+
542
+ **OpenAIフォーマット:**
543
+ ```json
544
+ {
545
+ "model": "gemini-2.5-pro",
546
+ "messages": [
547
+ {"role": "system", "content": "You are a helpful assistant"},
548
+ {"role": "user", "content": "Hello"}
549
+ ],
550
+ "temperature": 0.7,
551
+ "stream": true
552
+ }
553
+ ```
554
+
555
+ **Geminiネイティブフォーマット:**
556
+ ```json
557
+ {
558
+ "model": "gemini-2.5-pro",
559
+ "contents": [
560
+ {"role": "user", "parts": [{"text": "Hello"}]}
561
+ ],
562
+ "systemInstruction": {"parts": [{"text": "You are a helpful assistant"}]},
563
+ "generationConfig": {
564
+ "temperature": 0.7
565
+ }
566
+ }
567
+ ```
568
+
569
+ #### 2. Geminiネイティブエンドポイント(GCLI)
570
+
571
+ **非ストリーミングエンドポイント:** `/v1/models/{model}:generateContent`
572
+ **ストリーミングエンドポイント:** `/v1/models/{model}:streamGenerateContent`
573
+ **モデル一覧:** `/v1/models`
574
+
575
+ **認証方式(いずれか1つを選択):**
576
+ - `Authorization: Bearer your_api_password`
577
+ - `x-goog-api-key: your_api_password`
578
+ - URLパラメータ: `?key=your_api_password`
579
+
580
+ **リクエスト例:**
581
+ ```bash
582
+ # x-goog-api-keyヘッダーを使用
583
+ curl -X POST "http://127.0.0.1:7861/v1/models/gemini-2.5-pro:generateContent" \
584
+ -H "x-goog-api-key: your_api_password" \
585
+ -H "Content-Type: application/json" \
586
+ -d '{
587
+ "contents": [
588
+ {"role": "user", "parts": [{"text": "Hello"}]}
589
+ ]
590
+ }'
591
+
592
+ # URLパラメータを使用
593
+ curl -X POST "http://127.0.0.1:7861/v1/models/gemini-2.5-pro:streamGenerateContent?key=your_api_password" \
594
+ -H "Content-Type: application/json" \
595
+ -d '{
596
+ "contents": [
597
+ {"role": "user", "parts": [{"text": "Hello"}]}
598
+ ]
599
+ }'
600
+ ```
601
+
602
+ #### 3. Claude APIフォーマットエンドポイント
603
+
604
+ **エンドポイント:** `/v1/messages`
605
+ **認証:** `x-api-key: your_api_password` または `Authorization: Bearer your_api_password`
606
+
607
+ **リクエスト例:**
608
+ ```bash
609
+ curl -X POST "http://127.0.0.1:7861/v1/messages" \
610
+ -H "x-api-key: your_api_password" \
611
+ -H "anthropic-version: 2023-06-01" \
612
+ -H "Content-Type: application/json" \
613
+ -d '{
614
+ "model": "gemini-2.5-pro",
615
+ "max_tokens": 1024,
616
+ "messages": [
617
+ {"role": "user", "content": "Hello, Claude!"}
618
+ ]
619
+ }'
620
+ ```
621
+
622
+ **systemパラメータの対応:**
623
+ ```json
624
+ {
625
+ "model": "gemini-2.5-pro",
626
+ "max_tokens": 1024,
627
+ "system": "You are a helpful assistant",
628
+ "messages": [
629
+ {"role": "user", "content": "Hello"}
630
+ ]
631
+ }
632
+ ```
633
+
634
+ **注意事項:**
635
+ - Claude APIフォーマット仕様に完全互換
636
+ - バックエンド呼び出し時にGeminiフォーマットへ自動変換
637
+ - すべてのClaude標準パラメータに対応
638
+ - レスポンスフォーマットはClaude API仕様に準拠
639
+
640
+ ## 📋 完全なAPIリファレンス
641
+
642
+ ### Webコンソール API
643
+
644
+ **認証エンドポイント**
645
+ - `POST /auth/login` - ユーザーログイン
646
+ - `POST /auth/start` - OAuth認証の開始(GCLIおよびAntigravityモード対応)
647
+ - `POST /auth/callback` - OAuthコールバックの処理
648
+ - `POST /auth/callback-url` - コールバックURLから直接認証を完了
649
+ - `GET /auth/status/{project_id}` - 認証ステータスの確認
650
+
651
+ **クレデンシャル管理エンドポイント**(`mode=geminicli` または `mode=antigravity` パラメータ対応)
652
+ - `POST /creds/upload` - クレデンシャルファイルの一括アップロード(JSONおよびZIP対応)
653
+ - `GET /creds/status` - クレデンシャルステータス一覧の取得(ページネーションとフィルタリング対応)
654
+ - `GET /creds/detail/{filename}` - 単一クレデンシャルの詳細取得
655
+ - `POST /creds/action` - 単一クレデンシャル操作(有効化/無効化/削除)
656
+ - `POST /creds/batch-action` - クレデンシャルの一括操作
657
+ - `GET /creds/download/{filename}` - 単一クレデンシャルファイルのダウンロード
658
+ - `GET /creds/download-all` - 全クレデンシャルの一括ダウンロード
659
+ - `POST /creds/fetch-email/{filename}` - ユーザーメールの取得
660
+ - `POST /creds/refresh-all-emails` - ユーザーメールの一括更新
661
+ - `POST /creds/deduplicate-by-email` - メールによるクレデンシャルの重複排除
662
+ - `POST /creds/verify-project/{filename}` - クレデンシャルのProject ID検証
663
+ - `GET /creds/quota/{filename}` - クレデンシャルのクォータ情報取得(Antigravityのみ)
664
+
665
+ **設定管理エンドポイント**
666
+ - `GET /config/get` - 現在の���定の取得
667
+ - `POST /config/save` - 設定の保存
668
+
669
+ **ログ管理エンドポイント**
670
+ - `POST /logs/clear` - ログのクリア
671
+ - `GET /logs/download` - ログファイルのダウンロード
672
+ - `WebSocket /logs/stream` - リアルタイムログストリーム
673
+
674
+ **バージョン情報エンドポイント**
675
+ - `GET /version/info` - バージョン情報の取得(オプション `check_update=true` パラメータで更新確認)
676
+
677
+ ### チャットAPI機能
678
+
679
+ **マルチモーダル対応**
680
+ ```json
681
+ {
682
+ "model": "gemini-2.5-pro",
683
+ "messages": [
684
+ {
685
+ "role": "user",
686
+ "content": [
687
+ {"type": "text", "text": "この画像を説明してください"},
688
+ {
689
+ "type": "image_url",
690
+ "image_url": {
691
+ "url": "data:image/jpeg;base64,/9j/4AAQSkZJRgABA..."
692
+ }
693
+ }
694
+ ]
695
+ }
696
+ ]
697
+ }
698
+ ```
699
+
700
+ **Thinkingモード対応**
701
+ ```json
702
+ {
703
+ "model": "gemini-2.5-pro-high",
704
+ "messages": [
705
+ {"role": "user", "content": "複雑な数学の問題"}
706
+ ]
707
+ }
708
+ ```
709
+
710
+ レスポンスには分離された思考内容が含まれます:
711
+ ```json
712
+ {
713
+ "choices": [{
714
+ "message": {
715
+ "role": "assistant",
716
+ "content": "最終回答",
717
+ "reasoning_content": "詳細な思考プロセス..."
718
+ }
719
+ }]
720
+ }
721
+ ```
722
+
723
+ **ストリーミング途切れ防止の使用方法**
724
+ ```json
725
+ {
726
+ "model": "流式抗截断/gemini-2.5-pro",
727
+ "messages": [
728
+ {"role": "user", "content": "長い記事を書いてください"}
729
+ ],
730
+ "stream": true
731
+ }
732
+ ```
733
+
734
+ **互換モード**
735
+ ```bash
736
+ # 互換モードを有効化
737
+ export COMPATIBILITY_MODE=true
738
+ ```
739
+ このモードでは、すべての `system` メッセージが `user` メッセージに変換され、特定のクライアントとの互換性が向上します。
740
+
741
+ ---
742
+
743
+ ## 💬 コミュニティ
744
+
745
+ QQグループへの参加をお待ちしています!
746
+
747
+ **QQグループ: 1083250744**
748
+
749
+ <img src="qq群.jpg" width="200" alt="QQグループQRコード">
750
+
751
+ ---
752
+
753
+ ## ライセンスと免責事項
754
+
755
+ 本プロジェクトは学習および研究目的のみに使用できます。本プロジェクトの使用は、以下に同意したことを意味します:
756
+ - 本プロジェクトをいかなる商用目的にも使用しないこと
757
+ - 本プロジェクトの使用に伴うすべてのリスクと責任を負うこと
758
+ - 関連するサービス利用規約および法的規制を遵守すること
759
+
760
+ プロジェクトの作者は、本プロジェクトの使用から生じるいかなる直接的または間接的な損害についても責任を負いません。
front/common.js ADDED
The diff for this file is too large to render. See raw diff
 
front/control_panel.html ADDED
The diff for this file is too large to render. See raw diff
 
front/control_panel_mobile.html ADDED
The diff for this file is too large to render. See raw diff
 
install.ps1 ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 检测是否为管理员
2
+ $IsElevated = ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).
3
+ IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
4
+
5
+ # Skip Scoop install if already present to avoid stopping the script
6
+ if (Get-Command scoop -ErrorAction SilentlyContinue) {
7
+ Write-Host "Scoop is already installed. Skipping installation."
8
+ } else {
9
+ Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser -Force
10
+ if ($IsElevated) {
11
+ # 管理员:使用官方一行命令并传入 -RunAsAdmin
12
+ Invoke-Expression "& {$(Invoke-RestMethod get.scoop.sh)} -RunAsAdmin"
13
+ } else {
14
+ # 普通用户安装
15
+ Invoke-WebRequest -useb get.scoop.sh | Invoke-Expression
16
+ }
17
+ }
18
+
19
+ scoop install git uv
20
+ if (Test-Path -LiteralPath "./web.py") {
21
+ # Already in target directory; skip clone and cd
22
+ }
23
+ elseif (Test-Path -LiteralPath "./gcli2api/web.py") {
24
+ Set-Location ./gcli2api
25
+ }
26
+ else {
27
+ git clone https://github.com/su-kaka/gcli2api.git
28
+ Set-Location ./gcli2api
29
+ }
30
+ # Create relocatable virtual environment to ensure portability
31
+ $env:UV_VENV_CLEAR = "1"
32
+ uv venv --relocatable
33
+ uv sync
34
+ .venv/Scripts/activate.ps1
35
+ python web.py
install.sh ADDED
@@ -0,0 +1,302 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+ set -e # Exit on error
3
+ set -u # Exit on undefined variable
4
+ set -o pipefail # Exit on pipe failure
5
+
6
+ # Color codes for output
7
+ RED='\033[0;31m'
8
+ GREEN='\033[0;32m'
9
+ YELLOW='\033[1;33m'
10
+ BLUE='\033[0;34m'
11
+ NC='\033[0m' # No Color
12
+
13
+ # Logging functions
14
+ log_info() {
15
+ echo -e "${GREEN}[INFO]${NC} $1"
16
+ }
17
+
18
+ log_error() {
19
+ echo -e "${RED}[ERROR]${NC} $1" >&2
20
+ }
21
+
22
+ log_warn() {
23
+ echo -e "${YELLOW}[WARN]${NC} $1"
24
+ }
25
+
26
+ log_debug() {
27
+ echo -e "${BLUE}[DEBUG]${NC} $1"
28
+ }
29
+
30
+ # Cleanup function for error handling
31
+ cleanup() {
32
+ local exit_code=$?
33
+ if [ $exit_code -ne 0 ]; then
34
+ log_error "Installation failed with exit code $exit_code"
35
+ fi
36
+ exit $exit_code
37
+ }
38
+
39
+ trap cleanup EXIT
40
+
41
+ # Detect OS and distribution
42
+ detect_os() {
43
+ log_info "Detecting operating system..."
44
+
45
+ if [[ "$OSTYPE" == "linux-gnu"* ]]; then
46
+ if [ -f /etc/os-release ]; then
47
+ . /etc/os-release
48
+ OS_NAME=$ID
49
+ OS_VERSION=$VERSION_ID
50
+ log_info "Detected: $NAME $VERSION_ID"
51
+ elif [ -f /etc/lsb-release ]; then
52
+ . /etc/lsb-release
53
+ OS_NAME=$DISTRIB_ID
54
+ OS_VERSION=$DISTRIB_RELEASE
55
+ log_info "Detected: $DISTRIB_ID $DISTRIB_RELEASE"
56
+ else
57
+ OS_NAME="linux"
58
+ OS_VERSION="unknown"
59
+ log_warn "Could not determine specific Linux distribution"
60
+ fi
61
+ elif [[ "$OSTYPE" == "darwin"* ]]; then
62
+ OS_NAME="macos"
63
+ OS_VERSION=$(sw_vers -productVersion)
64
+ log_info "Detected: macOS $OS_VERSION"
65
+ elif [[ "$OSTYPE" == "freebsd"* ]]; then
66
+ OS_NAME="freebsd"
67
+ OS_VERSION=$(freebsd-version)
68
+ log_info "Detected: FreeBSD $OS_VERSION"
69
+ else
70
+ log_error "Unsupported operating system: $OSTYPE"
71
+ exit 1
72
+ fi
73
+ }
74
+
75
+ # Check for root privileges (only for Linux package managers that need it)
76
+ check_root_if_needed() {
77
+ if [[ "$OS_NAME" == "ubuntu" ]] || [[ "$OS_NAME" == "debian" ]] || [[ "$OS_NAME" == "linuxmint" ]] || [[ "$OS_NAME" == "kali" ]]; then
78
+ if [ "$EUID" -ne 0 ]; then
79
+ log_error "This script requires root privileges for apt. Please run with sudo."
80
+ exit 1
81
+ fi
82
+ elif [[ "$OS_NAME" == "fedora" ]] || [[ "$OS_NAME" == "rhel" ]] || [[ "$OS_NAME" == "centos" ]] || [[ "$OS_NAME" == "rocky" ]] || [[ "$OS_NAME" == "almalinux" ]]; then
83
+ if [ "$EUID" -ne 0 ]; then
84
+ log_error "This script requires root privileges for dnf/yum. Please run with sudo."
85
+ exit 1
86
+ fi
87
+ elif [[ "$OS_NAME" == "arch" ]] || [[ "$OS_NAME" == "manjaro" ]]; then
88
+ if [ "$EUID" -ne 0 ]; then
89
+ log_error "This script requires root privileges for pacman. Please run with sudo."
90
+ exit 1
91
+ fi
92
+ fi
93
+ }
94
+
95
+ # Update package manager
96
+ update_packages() {
97
+ log_info "Updating package manager..."
98
+
99
+ case "$OS_NAME" in
100
+ ubuntu|debian|linuxmint|kali|pop)
101
+ if ! apt update; then
102
+ log_error "Failed to update apt package lists"
103
+ exit 1
104
+ fi
105
+ ;;
106
+ fedora|rhel|centos|rocky|almalinux)
107
+ if command -v dnf &> /dev/null; then
108
+ if ! dnf check-update; then
109
+ # dnf check-update returns 100 if updates are available, which is not an error
110
+ if [ $? -ne 100 ]; then
111
+ log_warn "dnf check-update returned non-standard exit code"
112
+ fi
113
+ fi
114
+ else
115
+ if ! yum check-update; then
116
+ if [ $? -ne 100 ]; then
117
+ log_warn "yum check-update returned non-standard exit code"
118
+ fi
119
+ fi
120
+ fi
121
+ ;;
122
+ arch|manjaro)
123
+ if ! pacman -Syu; then
124
+ log_error "Failed to update pacman database"
125
+ exit 1
126
+ fi
127
+ ;;
128
+ macos)
129
+ if command -v brew &> /dev/null; then
130
+ log_info "Updating Homebrew..."
131
+ brew update
132
+ else
133
+ log_warn "Homebrew not installed. Skipping package manager update."
134
+ fi
135
+ ;;
136
+ *)
137
+ log_warn "Unknown package manager for $OS_NAME. Skipping update."
138
+ ;;
139
+ esac
140
+ }
141
+
142
+ # Install git based on OS
143
+ install_git() {
144
+ if ! command -v git &> /dev/null; then
145
+ log_info "Installing git..."
146
+
147
+ case "$OS_NAME" in
148
+ ubuntu|debian|linuxmint|kali|pop)
149
+ if ! apt install git -y; then
150
+ log_error "Failed to install git"
151
+ exit 1
152
+ fi
153
+ ;;
154
+ fedora|rhel|centos|rocky|almalinux)
155
+ if command -v dnf &> /dev/null; then
156
+ if ! dnf install git -y; then
157
+ log_error "Failed to install git"
158
+ exit 1
159
+ fi
160
+ else
161
+ if ! yum install git -y; then
162
+ log_error "Failed to install git"
163
+ exit 1
164
+ fi
165
+ fi
166
+ ;;
167
+ arch|manjaro)
168
+ if ! pacman -S git --noconfirm; then
169
+ log_error "Failed to install git"
170
+ exit 1
171
+ fi
172
+ ;;
173
+ macos)
174
+ if command -v brew &> /dev/null; then
175
+ if ! brew install git; then
176
+ log_error "Failed to install git"
177
+ exit 1
178
+ fi
179
+ else
180
+ log_error "Homebrew is required for macOS. Install from https://brew.sh/"
181
+ exit 1
182
+ fi
183
+ ;;
184
+ *)
185
+ log_error "Don't know how to install git on $OS_NAME"
186
+ exit 1
187
+ ;;
188
+ esac
189
+ else
190
+ log_info "Git is already installed ($(git --version))"
191
+ fi
192
+ }
193
+
194
+ # Detect OS first
195
+ detect_os
196
+
197
+ # Check root if needed
198
+ check_root_if_needed
199
+
200
+ log_info "Starting installation process..."
201
+
202
+ # Update package lists
203
+ update_packages
204
+
205
+ # Install git
206
+ install_git
207
+
208
+ # Install uv if not present
209
+ if ! command -v uv &> /dev/null; then
210
+ log_info "Installing uv package manager..."
211
+ if ! curl -Ls https://astral.sh/uv/install.sh | sh; then
212
+ log_error "Failed to install uv"
213
+ exit 1
214
+ fi
215
+
216
+ # Source environment
217
+ if [ -f "$HOME/.local/bin/env" ]; then
218
+ source "$HOME/.local/bin/env"
219
+ elif [ -f "$HOME/.cargo/env" ]; then
220
+ source "$HOME/.cargo/env"
221
+ fi
222
+
223
+ # Verify uv installation
224
+ if ! command -v uv &> /dev/null; then
225
+ log_error "uv installation failed - command not found after install"
226
+ exit 1
227
+ fi
228
+ else
229
+ log_info "uv is already installed"
230
+ fi
231
+
232
+ # Determine working directory
233
+ log_info "Checking project directory..."
234
+ if [ -f "./web.py" ]; then
235
+ log_info "Already in target directory"
236
+ elif [ -f "./gcli2api/web.py" ]; then
237
+ log_info "Changing to gcli2api directory"
238
+ cd ./gcli2api || exit 1
239
+ else
240
+ log_info "Cloning repository..."
241
+ if [ -d "./gcli2api" ]; then
242
+ log_warn "gcli2api directory exists but web.py not found. Removing and re-cloning..."
243
+ rm -rf ./gcli2api
244
+ fi
245
+
246
+ if ! git clone https://github.com/su-kaka/gcli2api.git; then
247
+ log_error "Failed to clone repository"
248
+ exit 1
249
+ fi
250
+
251
+ cd ./gcli2api || exit 1
252
+ fi
253
+
254
+ # Update repository if it's a git repo
255
+ if [ -d ".git" ]; then
256
+ log_info "Updating repository..."
257
+ if ! git pull; then
258
+ log_warn "Git pull failed, continuing anyway..."
259
+ fi
260
+ else
261
+ log_warn "Not a git repository, skipping update"
262
+ fi
263
+
264
+ # Create relocatable virtual environment to ensure portability
265
+ log_info "Creating relocatable virtual environment..."
266
+ export UV_VENV_CLEAR=1
267
+ if ! uv venv --relocatable; then
268
+ log_error "Failed to create virtual environment"
269
+ exit 1
270
+ fi
271
+
272
+ # Sync dependencies
273
+ log_info "Syncing dependencies with uv..."
274
+ if ! uv sync; then
275
+ log_error "Failed to sync dependencies"
276
+ exit 1
277
+ fi
278
+
279
+ # Activate virtual environment
280
+ log_info "Activating virtual environment..."
281
+ if [ -f ".venv/bin/activate" ]; then
282
+ source .venv/bin/activate
283
+ else
284
+ log_error "Virtual environment not found at .venv/bin/activate"
285
+ exit 1
286
+ fi
287
+
288
+ # Verify Python is available
289
+ if ! command -v python3 &> /dev/null; then
290
+ log_error "python3 not found in virtual environment"
291
+ exit 1
292
+ fi
293
+
294
+ # Check if web.py exists
295
+ if [ ! -f "web.py" ]; then
296
+ log_error "web.py not found in current directory"
297
+ exit 1
298
+ fi
299
+
300
+ # Start the application
301
+ log_info "Starting application..."
302
+ python3 web.py
log.py ADDED
@@ -0,0 +1,327 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 日志模块 - 使用环境变量配置
3
+ """
4
+
5
+ import os
6
+ import sys
7
+ import threading
8
+ from datetime import datetime
9
+ from collections import deque
10
+ import atexit
11
+
12
+ # 日志级别定义
13
+ LOG_LEVELS = {"debug": 0, "info": 1, "warning": 2, "error": 3, "critical": 4}
14
+
15
+ # 文件写入状态标志(仅由 writer 线程修改,无需锁保护)
16
+ _file_writing_disabled = False
17
+ _disable_reason = None
18
+
19
+ # 全局文件句柄(仅由 writer 线程访问,无需文件锁)
20
+ _log_file_handle = None
21
+
22
+ # -----------------------------------------------------------------
23
+ # 高性能无锁队列:用 deque + Condition 替代 Queue
24
+ # deque.append / deque.popleft 在 CPython 中受 GIL 保护,是原子操作,
25
+ # 不需要额外的 Lock 做入队保护,只用 Condition 做"有数据"通知。
26
+ # -----------------------------------------------------------------
27
+ _log_deque: deque = deque()
28
+ _deque_condition = threading.Condition(threading.Lock())
29
+ _writer_thread = None
30
+ _writer_running = False
31
+
32
+ # -----------------------------------------------------------------
33
+ # 缓存日志级别,避免每次都读 os.getenv(高并发热路径)
34
+ # -----------------------------------------------------------------
35
+ _cached_log_level: int = LOG_LEVELS["info"]
36
+ _cached_log_file: str = "log.txt"
37
+ # ENABLE_LOG=0/false/no/off 时彻底关闭日志
38
+ _log_enabled: bool = True
39
+
40
+
41
+ def _refresh_config():
42
+ """从环境变量刷新缓存配置(模块加载时及需要时调用)"""
43
+ global _cached_log_level, _cached_log_file, _log_enabled
44
+ level = os.getenv("LOG_LEVEL", "info").lower()
45
+ _cached_log_level = LOG_LEVELS.get(level, LOG_LEVELS["info"])
46
+ _cached_log_file = os.getenv("LOG_FILE", "log.txt")
47
+ _log_enabled = os.getenv("ENABLE_LOG", "1").strip().lower() not in ("0", "false", "no", "off")
48
+
49
+
50
+ def _get_current_log_level() -> int:
51
+ return _cached_log_level
52
+
53
+
54
+ def _get_log_file_path() -> str:
55
+ return _cached_log_file
56
+
57
+
58
+ # -----------------------------------------------------------------
59
+ # 文件句柄管理(仅在 writer 线程内调用,不需要 _file_lock)
60
+ # -----------------------------------------------------------------
61
+
62
+ def _close_log_file():
63
+ global _log_file_handle
64
+ if _log_file_handle is not None:
65
+ try:
66
+ _log_file_handle.flush()
67
+ _log_file_handle.close()
68
+ except Exception:
69
+ pass
70
+ finally:
71
+ _log_file_handle = None
72
+
73
+
74
+ def _open_log_file(mode: str = "a") -> bool:
75
+ global _log_file_handle, _file_writing_disabled, _disable_reason
76
+ _close_log_file()
77
+ try:
78
+ # 使用较大缓冲区(64 KB),由 writer 线程定期 flush,减少系统调用
79
+ _log_file_handle = open(_cached_log_file, mode, encoding="utf-8", buffering=65536)
80
+ return True
81
+ except (PermissionError, OSError, IOError) as e:
82
+ _file_writing_disabled = True
83
+ _disable_reason = str(e)
84
+ print(f"Warning: Cannot open log file, disabling file writing: {e}", file=sys.stderr)
85
+ print("Log messages will continue to display in console only.", file=sys.stderr)
86
+ return False
87
+ except Exception as e:
88
+ print(f"Warning: Failed to open log file: {e}", file=sys.stderr)
89
+ return False
90
+
91
+
92
+ def _clear_log_file():
93
+ """清空日志文件(启动时调用,此时 writer 线程尚未启动,直接操作安全)"""
94
+ global _file_writing_disabled, _disable_reason
95
+ try:
96
+ with open(_cached_log_file, "w", encoding="utf-8") as f:
97
+ pass # 覆盖清空
98
+ _open_log_file("a")
99
+ except (PermissionError, OSError, IOError) as e:
100
+ _file_writing_disabled = True
101
+ _disable_reason = str(e)
102
+ print(
103
+ f"Warning: File system appears to be read-only or permission denied. "
104
+ f"Disabling log file writing: {e}",
105
+ file=sys.stderr,
106
+ )
107
+ print("Log messages will continue to display in console only.", file=sys.stderr)
108
+ except Exception as e:
109
+ print(f"Warning: Failed to clear log file: {e}", file=sys.stderr)
110
+
111
+
112
+ # -----------------------------------------------------------------
113
+ # Writer 线程:批量从 deque 取出并写入,减少系统调用次数
114
+ # -----------------------------------------------------------------
115
+ _BATCH_SIZE = 1000 # 单次最多批量写入条数
116
+ _FLUSH_INTERVAL = 2 # 秒:无新消息时强制 flush 周期
117
+
118
+
119
+ def _log_writer_worker():
120
+ global _writer_running
121
+
122
+ last_flush_time = 0.0
123
+
124
+ while True:
125
+ # 等待数据或超时
126
+ with _deque_condition:
127
+ if not _log_deque and _writer_running:
128
+ _deque_condition.wait(timeout=_FLUSH_INTERVAL)
129
+
130
+ # 批量取出
131
+ batch = []
132
+ for _ in range(_BATCH_SIZE):
133
+ if _log_deque:
134
+ batch.append(_log_deque.popleft())
135
+ else:
136
+ break
137
+
138
+ if batch and not _file_writing_disabled:
139
+ # 一次 write 调用��定整批,最大化减少系统调用
140
+ chunk = "\n".join(batch) + "\n"
141
+ try:
142
+ if _log_file_handle is None:
143
+ _open_log_file("a")
144
+ if _log_file_handle is not None:
145
+ _log_file_handle.write(chunk)
146
+ except Exception as e:
147
+ print(f"Warning: Failed to write log batch: {e}", file=sys.stderr)
148
+ _close_log_file()
149
+ try:
150
+ _open_log_file("a")
151
+ except Exception:
152
+ pass
153
+
154
+ # 定时 flush
155
+ now = _now_ts()
156
+ if now - last_flush_time >= _FLUSH_INTERVAL:
157
+ if _log_file_handle is not None:
158
+ try:
159
+ _log_file_handle.flush()
160
+ except Exception:
161
+ pass
162
+ last_flush_time = now
163
+
164
+ # 退出条件:已停止 + deque 已清空
165
+ if not _writer_running and not _log_deque:
166
+ break
167
+
168
+ # 最终 flush & close
169
+ if _log_file_handle is not None:
170
+ try:
171
+ _log_file_handle.flush()
172
+ except Exception:
173
+ pass
174
+ _close_log_file()
175
+
176
+
177
+ def _now_ts() -> float:
178
+ import time
179
+ return time.monotonic()
180
+
181
+
182
+ def _start_writer_thread():
183
+ global _writer_thread, _writer_running
184
+
185
+ if _writer_thread is None or not _writer_thread.is_alive():
186
+ _writer_running = True
187
+ _writer_thread = threading.Thread(target=_log_writer_worker, daemon=True, name="LogWriter")
188
+ _writer_thread.start()
189
+
190
+
191
+ def _stop_writer_thread():
192
+ global _writer_running
193
+
194
+ _writer_running = False
195
+ # 唤醒 writer 线程让它能感知退出信号
196
+ with _deque_condition:
197
+ _deque_condition.notify_all()
198
+
199
+ if _writer_thread and _writer_thread.is_alive():
200
+ _writer_thread.join(timeout=3.0)
201
+
202
+
203
+ # -----------------------------------------------------------------
204
+ # 入队(热路径,极轻量)
205
+ # -----------------------------------------------------------------
206
+ _MAX_QUEUE_SIZE = 5000 # 防止极端情况内存无限增长
207
+
208
+
209
+ def _write_to_file(message: str):
210
+ if _file_writing_disabled:
211
+ return
212
+ # deque.append 在 CPython 受 GIL 保护,无需额外锁
213
+ if len(_log_deque) >= _MAX_QUEUE_SIZE:
214
+ return # 过载保护:丢弃而非阻塞
215
+ _log_deque.append(message)
216
+ # 非阻塞通知 writer(acquire 失败直接跳过,不影响主线程)
217
+ if _deque_condition.acquire(blocking=False):
218
+ try:
219
+ _deque_condition.notify()
220
+ finally:
221
+ _deque_condition.release()
222
+
223
+
224
+ # -----------------------------------------------------------------
225
+ # 核心日志函数(热路径)
226
+ # -----------------------------------------------------------------
227
+
228
+ def _log(level: str, message: str):
229
+ # 最快短路:日志整体已禁用时直接返回,零开销
230
+ if not _log_enabled:
231
+ return
232
+
233
+ level = level.lower()
234
+ level_val = LOG_LEVELS.get(level)
235
+ if level_val is None:
236
+ print(f"Warning: Unknown log level '{level}'", file=sys.stderr)
237
+ return
238
+
239
+ # 热路径:直接与缓存值比较,无函数调用开销
240
+ if level_val < _cached_log_level:
241
+ return
242
+
243
+ timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
244
+ entry = f"[{timestamp}] [{level.upper()}] {message}"
245
+
246
+ if level in ("error", "critical"):
247
+ print(entry, file=sys.stderr)
248
+ else:
249
+ print(entry)
250
+
251
+ _write_to_file(entry)
252
+
253
+
254
+ def set_log_level(level: str):
255
+ """动态设置日志级别(同时更新缓存)"""
256
+ global _cached_log_level
257
+ level = level.lower()
258
+ if level not in LOG_LEVELS:
259
+ print(f"Warning: Unknown log level '{level}'. Valid levels: {', '.join(LOG_LEVELS.keys())}")
260
+ return False
261
+ _cached_log_level = LOG_LEVELS[level]
262
+ return True
263
+
264
+
265
+ class Logger:
266
+ """支持 log('info', 'msg') 和 log.info('msg') 两种调用方式"""
267
+
268
+ def __call__(self, level: str, message: str):
269
+ _log(level, message)
270
+
271
+ def debug(self, message: str):
272
+ _log("debug", message)
273
+
274
+ def info(self, message: str):
275
+ _log("info", message)
276
+
277
+ def warning(self, message: str):
278
+ _log("warning", message)
279
+
280
+ def error(self, message: str):
281
+ _log("error", message)
282
+
283
+ def critical(self, message: str):
284
+ _log("critical", message)
285
+
286
+ def get_current_level(self) -> str:
287
+ current_level = _get_current_log_level()
288
+ for name, value in LOG_LEVELS.items():
289
+ if value == current_level:
290
+ return name
291
+ return "info"
292
+
293
+ def get_log_file(self) -> str:
294
+ return _get_log_file_path()
295
+
296
+ def close(self):
297
+ """手动关闭(优雅退出用)"""
298
+ _stop_writer_thread()
299
+
300
+ def get_queue_size(self) -> int:
301
+ return len(_log_deque)
302
+
303
+
304
+ # 导出全局日志实例
305
+ log = Logger()
306
+
307
+ # 导出的公共接口
308
+ __all__ = ["log", "set_log_level", "LOG_LEVELS"]
309
+
310
+ # 模块加载时:读取配置���存 → 清空日志文件 → 启动 writer 线程
311
+ _refresh_config()
312
+ if _log_enabled:
313
+ _clear_log_file()
314
+ _start_writer_thread()
315
+
316
+ # 注册退出清理
317
+ atexit.register(_stop_writer_thread)
318
+
319
+ # 使用说明:
320
+ # 1. 设置日志级别: export LOG_LEVEL=debug (或在 .env 中设置)
321
+ # 2. 设置日志文件: export LOG_FILE=log.txt (或在 .env 中设置)
322
+ # 3. 日志级别已缓存,热路径零 os.getenv 调用
323
+ # 4. 写入线程批量处理(最多 200 条/次),64 KB 缓冲区,每 0.5 s flush 一次
324
+ # 5. 队列上限 5000 条,超出时丢弃新日志(过载保护,不阻塞主线程)
325
+ # 6. 动态调整级别:set_log_level('debug') 立即生效
326
+ # 7. 彻底关闭日志(最高性能):export ENABLE_LOG=0 (或 false/no/off)
327
+ # 关闭后不会启动 writer 线程、不写文件、不打印控制台,_log 直接 return
pyproject.toml ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [project]
2
+ name = "gcli2api"
3
+ version = "0.1.0"
4
+ description = "Convert GeminiCLI to OpenAI and Gemini API interfaces"
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ license = {text = "CNC-1.0"}
8
+ authors = [
9
+ {name = "su-kaka"}
10
+ ]
11
+ keywords = ["gemini", "openai", "api", "converter", "cli"]
12
+ classifiers = [
13
+ "Development Status :: 4 - Beta",
14
+ "Intended Audience :: Developers",
15
+ "License :: Other/Proprietary License",
16
+ "Programming Language :: Python :: 3",
17
+ "Programming Language :: Python :: 3.13",
18
+ ]
19
+ dependencies = [
20
+ "aiofiles>=24.1.0",
21
+ "fastapi>=0.116.1",
22
+ "httpx[socks]>=0.28.1",
23
+ "hypercorn>=0.17.3",
24
+ "motor>=3.7.1",
25
+ "oauthlib>=3.3.1",
26
+ "pydantic>=2.11.7",
27
+ "pyjwt>=2.10.1",
28
+ "python-dotenv>=1.1.1",
29
+ "python-multipart>=0.0.20",
30
+ "pypinyin>=0.51.0",
31
+ "aiosqlite>=0.20.0",
32
+ "redis>=7.2.0",
33
+ "asyncpg>=0.31.0",
34
+ ]
35
+
36
+ [project.optional-dependencies]
37
+ dev = [
38
+ "pytest>=8.0.0",
39
+ "pytest-asyncio>=0.23.0",
40
+ "pytest-cov>=4.1.0",
41
+ "black>=24.0.0",
42
+ "flake8>=7.0.0",
43
+ "mypy>=1.8.0",
44
+ "pre-commit>=3.6.0",
45
+ ]
46
+
47
+ [tool.pytest.ini_options]
48
+ minversion = "8.0"
49
+ testpaths = ["."]
50
+ python_files = ["test_*.py"]
51
+ python_classes = ["Test*"]
52
+ python_functions = ["test_*"]
53
+ asyncio_mode = "auto"
54
+ addopts = [
55
+ "-v",
56
+ "--strict-markers",
57
+ ]
58
+
59
+ [tool.black]
60
+ line-length = 100
61
+ target-version = ["py313"]
62
+ include = '\.pyi?$'
63
+ extend-exclude = '''
64
+ /(
65
+ # directories
66
+ \.eggs
67
+ | \.git
68
+ | \.hg
69
+ | \.mypy_cache
70
+ | \.tox
71
+ | \.venv
72
+ | build
73
+ | dist
74
+ )/
75
+ '''
76
+
77
+ [tool.mypy]
78
+ python_version = "3.13"
79
+ warn_return_any = true
80
+ warn_unused_configs = true
81
+ disallow_untyped_defs = false
82
+ ignore_missing_imports = true
83
+ exclude = [
84
+ "build",
85
+ "dist",
86
+ ]
87
+
88
+ [tool.coverage.run]
89
+ source = ["src"]
90
+ omit = [
91
+ "*/tests/*",
92
+ "*/test_*.py",
93
+ ]
94
+
95
+ [tool.coverage.report]
96
+ exclude_lines = [
97
+ "pragma: no cover",
98
+ "def __repr__",
99
+ "raise AssertionError",
100
+ "raise NotImplementedError",
101
+ "if __name__ == .__main__.:",
102
+ "if TYPE_CHECKING:",
103
+ ]
render.yaml ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ services:
2
+ - type: web
3
+ name: gcli2api
4
+ runtime: docker
5
+ dockerfilePath: ./Dockerfile
6
+ dockerContext: .
7
+ plan: free
8
+ region: singapore
9
+ healthCheckPath: /
10
+
11
+ envVars:
12
+ # ========== 必填:访问密码 ==========
13
+ - key: PASSWORD
14
+ sync: false # 部署时手动填写,不同步到代码库
15
+
16
+ # ========== 服务器配置 ==========
17
+ - key: HOST
18
+ value: 0.0.0.0
19
+ - key: PORT
20
+ value: "10000" # Render 要求 Web 服务监听 10000 端口
requirements-termux.txt ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ fastapi
2
+ httpx[socks]
3
+ pydantic==1.10.22
4
+ python-dotenv
5
+ hypercorn
6
+ aiofiles
7
+ python-multipart
8
+ PyJWT
9
+ oauthlib
10
+ motor
11
+ pypinyin
12
+ aiosqlite
13
+ redis
14
+ asyncpg
requirements.txt ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ fastapi>=0.116.1
2
+ httpx[socks]>=0.28.1
3
+ pydantic>=2.11.7
4
+ python-dotenv>=1.1.1
5
+ hypercorn>=0.17.3
6
+ aiofiles>=24.1.0
7
+ python-multipart>=0.0.20
8
+ PyJWT>=2.10.1
9
+ oauthlib>=3.3.1
10
+ motor>=3.7.1
11
+ aiosqlite>=0.20.0
12
+ pypinyin>=0.51.0
13
+ redis>=4.2.0
14
+ asyncpg
src/api/Response_example.txt ADDED
@@ -0,0 +1,210 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ================================================================================
2
+ GeminiCli API 测试
3
+ ================================================================================
4
+
5
+ ================================================================================
6
+ 【测试1】流式请求 (stream_request with native=False)
7
+ ================================================================================
8
+ 请求体: {
9
+ "model": "gemini-2.5-flash",
10
+ "request": {
11
+ "contents": [
12
+ {
13
+ "role": "user",
14
+ "parts": [
15
+ {
16
+ "text": "Hello, tell me a joke in one sentence."
17
+ }
18
+ ]
19
+ }
20
+ ]
21
+ }
22
+ }
23
+
24
+ 流式响应数据 (每个chunk):
25
+ --------------------------------------------------------------------------------
26
+ [2026-01-10 09:55:29] [INFO] SQLite storage initialized at ./creds\credentials.db
27
+ [2026-01-10 09:55:29] [INFO] Using SQLite storage backend
28
+ [2026-01-10 09:55:31] [INFO] Token刷新成 功并已保存: my-project-9-481103-1765596755.json (mode=geminicli)
29
+ [2026-01-10 09:55:34] [INFO] [DB] 准备commit,总更新行数=1
30
+ [2026-01-10 09:55:34] [INFO] [DB] commit 完成
31
+ [2026-01-10 09:55:34] [INFO] [DB] update_credential_state 结束: success=True, updated_count=1
32
+
33
+ Chunk #1:
34
+ 类型: str
35
+ 长度: 626
36
+ 内容预览: 'data: {"response": {"candidates": [{"content": {"role": "model","parts": [{"text": "Why did the scarecrow win an award? Because he was outstanding in his field."}]},"finishReason": "STOP"}],"usageMeta'
37
+ 解析后的JSON: {
38
+ "response": {
39
+ "candidates": [
40
+ {
41
+ "content": {
42
+ "role": "model",
43
+ "parts": [
44
+ {
45
+ "text": "Why did the scarecrow win an award? Because he was outstanding in his field."
46
+ }
47
+ ]
48
+ },
49
+ "finishReason": "STOP"
50
+ }
51
+ ],
52
+ "usageMetadata": {
53
+ "promptTokenCount": 10,
54
+ "candidatesTokenCount": 17,
55
+ "totalTokenCount": 51,
56
+ "trafficType": "PROVISIONED_THROUGHPUT",
57
+ "promptTokensDetails": [
58
+ {
59
+ "modality": "TEXT",
60
+ "tokenCount": 10
61
+ }
62
+ ],
63
+ "candidatesTokensDetails": [
64
+ {
65
+ "modality": "TEXT",
66
+ "tokenCount": 17
67
+ }
68
+ ],
69
+ "thoughtsTokenCount": 24
70
+ },
71
+ "modelVersion": "gemini-2.5-flash",
72
+ "createTime": "2026-01-10T01:55:29.168589Z",
73
+ "responseId": "kbFhaY2lCr-ZseMPqMiDmAU"
74
+ },
75
+ "traceId": "55650653afd3c738"
76
+ }
77
+
78
+ Chunk #2:
79
+ 类型: str
80
+ 长度: 0
81
+ 内容预览: ''
82
+ E:\projects\gcli2api\src\api\geminicli.py:491: RuntimeWarning: coroutine 'get_auto_ban_error_codes' was never awaited
83
+ async for chunk in stream_request(body=test_body, native=False):
84
+ RuntimeWarning: Enable tracemalloc to get the object allocation traceback
85
+
86
+ 总共收到 2 个chunk
87
+
88
+ ================================================================================
89
+ 【测试2】非流式请求 (non_stream_request)
90
+ ================================================================================
91
+ 请求体: {
92
+ "model": "gemini-2.5-flash",
93
+ "request": {
94
+ "contents": [
95
+ {
96
+ "role": "user",
97
+ "parts": [
98
+ {
99
+ "text": "Hello, tell me a joke in one sentence."
100
+ }
101
+ ]
102
+ }
103
+ ]
104
+ }
105
+ }
106
+
107
+ [2026-01-10 09:55:35] [INFO] Token刷新成 功并已保存: gen-lang-client-0194852792-1767296759.json (mode=geminicli)
108
+ [2026-01-10 09:55:38] [INFO] [DB] 准备commit,总更新行数=1
109
+ [2026-01-10 09:55:38] [INFO] [DB] commit 完成
110
+ [2026-01-10 09:55:38] [INFO] [DB] update_credential_state 结束: success=True, updated_count=1
111
+ E:\projects\gcli2api\src\api\geminicli.py:530: RuntimeWarning: coroutine 'get_auto_ban_error_codes' was never awaited
112
+ response = await non_stream_request(body=test_body)
113
+ RuntimeWarning: Enable tracemalloc to get the object allocation traceback
114
+ 非流式响应数据:
115
+ --------------------------------------------------------------------------------
116
+ 状态码: 200
117
+ Content-Type: application/json; charset=UTF-8
118
+
119
+ 响应头: {'server': 'openresty', 'date': 'Sat, 10 Jan 2026 01:55:34 GMT', 'content-type': 'application/json; charset=UTF-8', 'transfer-encoding': 'chunked', 'connection': 'keep-alive', 'x-cloudaicompanion-trace-id': 'bf3a5eb6636774d2', 'vary': 'Origin, X-Origin, Referer', 'content-encoding': 'gzip', 'x-xss-protection': '0', 'x-frame-options': 'SAMEORIGIN', 'x-content-type-options': 'nosniff', 'server-timing': 'gfet4t7; dur=1377', 'alt-svc': 'h3=":443"; ma=2592000,h3-29=":443"; ma=2592000', 'access-control-allow-origin': '*', 'access-control-allow-methods': 'GET, POST, PUT, DELETE, PATCH, OPTIONS', 'access-control-allow-headers': 'Content-Type, Authorization, X-Requested-With', 'cache-control': 'no-cache', 'content-length': '969'}
120
+
121
+ 响应内容 (原始):
122
+ {
123
+ "response": {
124
+ "candidates": [
125
+ {
126
+ "content": {
127
+ "role": "model",
128
+ "parts": [
129
+ {
130
+ "text": "Why did the scarecrow win an award? Because he was outstanding in his field!"
131
+ }
132
+ ]
133
+ },
134
+ "finishReason": "STOP",
135
+ "avgLogprobs": -0.54438119776108684
136
+ }
137
+ ],
138
+ "usageMetadata": {
139
+ "promptTokenCount": 10,
140
+ "candidatesTokenCount": 17,
141
+ "totalTokenCount": 47,
142
+ "trafficType": "PROVISIONED_THROUGHPUT",
143
+ "promptTokensDetails": [
144
+ {
145
+ "modality": "TEXT",
146
+ "tokenCount": 10
147
+ }
148
+ ],
149
+ "candidatesTokensDetails": [
150
+ {
151
+ "modality": "TEXT",
152
+ "tokenCount": 17
153
+ }
154
+ ],
155
+ "thoughtsTokenCount": 20
156
+ },
157
+ "modelVersion": "gemini-2.5-flash",
158
+ "createTime": "2026-01-10T01:55:33.450396Z",
159
+ "responseId": "lbFhady-G7yi694PmLOP4As"
160
+ },
161
+ "traceId": "bf3a5eb6636774d2"
162
+ }
163
+
164
+
165
+ 响应内容 (格式化JSON):
166
+ {
167
+ "response": {
168
+ "candidates": [
169
+ {
170
+ "content": {
171
+ "role": "model",
172
+ "parts": [
173
+ {
174
+ "text": "Why did the scarecrow win an award? Because he was outstanding in his field!"
175
+ }
176
+ ]
177
+ },
178
+ "finishReason": "STOP",
179
+ "avgLogprobs": -0.5443811977610868
180
+ }
181
+ ],
182
+ "usageMetadata": {
183
+ "promptTokenCount": 10,
184
+ "candidatesTokenCount": 17,
185
+ "totalTokenCount": 47,
186
+ "trafficType": "PROVISIONED_THROUGHPUT",
187
+ "promptTokensDetails": [
188
+ {
189
+ "modality": "TEXT",
190
+ "tokenCount": 10
191
+ }
192
+ ],
193
+ "candidatesTokensDetails": [
194
+ {
195
+ "modality": "TEXT",
196
+ "tokenCount": 17
197
+ }
198
+ ],
199
+ "thoughtsTokenCount": 20
200
+ },
201
+ "modelVersion": "gemini-2.5-flash",
202
+ "createTime": "2026-01-10T01:55:33.450396Z",
203
+ "responseId": "lbFhady-G7yi694PmLOP4As"
204
+ },
205
+ "traceId": "bf3a5eb6636774d2"
206
+ }
207
+
208
+ ================================================================================
209
+ 测试完成
210
+ ================================================================================
src/api/antigravity.py ADDED
@@ -0,0 +1,845 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Antigravity API Client - Handles communication with Google's Antigravity API
3
+ 处理与 Google Antigravity API 的通信
4
+ """
5
+
6
+ import asyncio
7
+ import json
8
+ import uuid
9
+ from datetime import datetime, timezone
10
+ from typing import Any, Dict, List, Optional, Callable, Tuple
11
+
12
+ from fastapi import Response
13
+ from config import (
14
+ get_code_assist_endpoint,
15
+ get_antigravity_stream2nostream,
16
+ get_auto_ban_error_codes,
17
+ )
18
+ from log import log
19
+
20
+ from src.credential_manager import credential_manager
21
+ from src.httpx_client import stream_post_async, post_async
22
+ from src.models import Model, model_to_dict
23
+ from src.utils import ANTIGRAVITY_USER_AGENT
24
+
25
+ # 导入共同的基础功能
26
+ from src.api.utils import (
27
+ handle_error_with_retry,
28
+ get_retry_config,
29
+ record_api_call_success,
30
+ record_api_call_error,
31
+ parse_and_log_cooldown,
32
+ collect_streaming_response,
33
+ )
34
+
35
+ # ==================== 全局凭证管理器 ====================
36
+
37
+ # 使用全局单例 credential_manager,自动初始化
38
+
39
+
40
+ # ==================== 辅助函数 ====================
41
+
42
+ def build_antigravity_headers(access_token: str, model_name: str = "") -> Dict[str, str]:
43
+ """
44
+ 构建 Antigravity API 请求头
45
+
46
+ Args:
47
+ access_token: 访问令牌
48
+ model_name: 模型名称,用于判断 request_type
49
+
50
+ Returns:
51
+ 请求头字典
52
+ """
53
+ headers = {
54
+ 'User-Agent': ANTIGRAVITY_USER_AGENT,
55
+ 'Authorization': f'Bearer {access_token}',
56
+ 'Content-Type': 'application/json',
57
+ 'Accept-Encoding': 'gzip',
58
+ 'requestId': f"req-{uuid.uuid4()}"
59
+ }
60
+
61
+ # 根据模型名称判断 request_type
62
+ if model_name:
63
+ # 先判断是否是图片模型
64
+ if "image" in model_name.lower():
65
+ request_type = "image_gen"
66
+ headers['requestType'] = request_type
67
+ else:
68
+ request_type = "agent"
69
+ headers['requestType'] = request_type
70
+
71
+ return headers
72
+
73
+
74
+ def _is_retryable_status(status_code: int, disable_error_codes: List[int]) -> bool:
75
+ """统一判断是否属于可重试状态码。"""
76
+ return status_code in (429, 503) or status_code in disable_error_codes
77
+
78
+
79
+ async def _switch_credential_for_retry(
80
+ *,
81
+ next_cred_task: Optional[asyncio.Task],
82
+ retry_interval: float,
83
+ refresh_credential_fast: Callable[[], Any],
84
+ apply_cred_result: Callable[[Tuple[str, Dict[str, Any]]], bool],
85
+ log_prefix: str,
86
+ ) -> Tuple[bool, Optional[asyncio.Task]]:
87
+ """优先使用预热凭证,失败后退回同步刷新。"""
88
+ if next_cred_task is not None:
89
+ try:
90
+ cred_result = await next_cred_task
91
+ next_cred_task = None
92
+ if cred_result and apply_cred_result(cred_result):
93
+ await asyncio.sleep(retry_interval)
94
+ return True, next_cred_task
95
+ except Exception as e:
96
+ log.warning(f"{log_prefix} 预热凭证任务失败: {e}")
97
+ next_cred_task = None
98
+
99
+ await asyncio.sleep(retry_interval)
100
+ if await refresh_credential_fast():
101
+ return True, next_cred_task
102
+
103
+ return False, next_cred_task
104
+
105
+
106
+ # ==================== 新的流式和非流式请求函数 ====================
107
+
108
+ async def stream_request(
109
+ body: Dict[str, Any],
110
+ native: bool = False,
111
+ headers: Optional[Dict[str, str]] = None,
112
+ ):
113
+ """
114
+ 流式请求函数
115
+
116
+ Args:
117
+ body: 请求体
118
+ native: 是否返回原生bytes流,False则返回str流
119
+ headers: 额外的请求头
120
+
121
+ Yields:
122
+ Response对象(错误时)或 bytes流/str流(成功时)
123
+ """
124
+ model_name = body.get("model", "")
125
+
126
+ # 1. 获取有效凭证
127
+ cred_result = await credential_manager.get_valid_credential(
128
+ mode="antigravity", model_name=model_name
129
+ )
130
+
131
+ if not cred_result:
132
+ # 如果返回值是None,直接返回错误500
133
+ log.error("[ANTIGRAVITY STREAM] 当前无可用凭证")
134
+ yield Response(
135
+ content=json.dumps({"error": "当前无可用凭证"}),
136
+ status_code=500,
137
+ media_type="application/json"
138
+ )
139
+ return
140
+
141
+ current_file, credential_data = cred_result
142
+ access_token = credential_data.get("access_token") or credential_data.get("token")
143
+ project_id = credential_data.get("project_id", "")
144
+
145
+ if not access_token:
146
+ log.error(f"[ANTIGRAVITY STREAM] No access token in credential: {current_file}")
147
+ yield Response(
148
+ content=json.dumps({"error": "凭证中没有访问令牌"}),
149
+ status_code=500,
150
+ media_type="application/json"
151
+ )
152
+ return
153
+
154
+ # 2. 构建URL和请求头
155
+ antigravity_url = await get_code_assist_endpoint()
156
+ target_url = f"{antigravity_url}/v1internal:streamGenerateContent?alt=sse"
157
+
158
+ auth_headers = build_antigravity_headers(access_token, model_name)
159
+
160
+ # 合并自定义headers
161
+ if headers:
162
+ auth_headers.update(headers)
163
+
164
+ # 构建包含project的payload
165
+ final_payload = {
166
+ "model": body.get("model"),
167
+ "project": project_id,
168
+ "request": body.get("request", {}),
169
+ }
170
+
171
+ # 仅当凭证明确开启积分消耗时注入 enabledCreditTypes
172
+ def apply_enabled_credit_types(cred_data: Dict[str, Any]) -> None:
173
+ if cred_data.get("enable_credit") is True:
174
+ final_payload["enabledCreditTypes"] = ["GOOGLE_ONE_AI"]
175
+ else:
176
+ final_payload.pop("enabledCreditTypes", None)
177
+
178
+ apply_enabled_credit_types(credential_data)
179
+
180
+ # 3. 调用stream_post_async进行请求
181
+ retry_config = await get_retry_config()
182
+ max_retries = retry_config["max_retries"]
183
+ retry_interval = retry_config["retry_interval"]
184
+
185
+ DISABLE_ERROR_CODES = await get_auto_ban_error_codes() # 禁用凭证的错误码
186
+ last_error_response = None # 记录最后一次的错误响应
187
+ next_cred_task = None # 预热的下一个凭证任务
188
+
189
+ # 内部函数:快速更新凭证(只更新token和project_id,避免重建整个请求)
190
+ async def refresh_credential_fast():
191
+ nonlocal current_file, access_token, auth_headers, project_id, final_payload
192
+ cred_result = await credential_manager.get_valid_credential(
193
+ mode="antigravity", model_name=model_name
194
+ )
195
+ if not cred_result:
196
+ return None
197
+ current_file, credential_data = cred_result
198
+ access_token = credential_data.get("access_token") or credential_data.get("token")
199
+ project_id = credential_data.get("project_id", "")
200
+ if not access_token:
201
+ return None
202
+ # 只更新token和project_id,不重建整个headers和payload
203
+ auth_headers["Authorization"] = f"Bearer {access_token}"
204
+ final_payload["project"] = project_id
205
+ apply_enabled_credit_types(credential_data)
206
+ return True
207
+
208
+ def apply_cred_result(cred_result: Tuple[str, Dict[str, Any]]) -> bool:
209
+ nonlocal current_file, access_token, project_id, auth_headers, final_payload
210
+ current_file, credential_data = cred_result
211
+ access_token = credential_data.get("access_token") or credential_data.get("token")
212
+ project_id = credential_data.get("project_id", "")
213
+ if not access_token or not project_id:
214
+ return False
215
+ auth_headers["Authorization"] = f"Bearer {access_token}"
216
+ final_payload["project"] = project_id
217
+ apply_enabled_credit_types(credential_data)
218
+ return True
219
+
220
+ for attempt in range(max_retries + 1):
221
+ success_recorded = False # 标记是否已记录成功
222
+ need_retry = False # 标记是否需要重试
223
+
224
+ try:
225
+ async for chunk in stream_post_async(
226
+ url=target_url,
227
+ body=final_payload,
228
+ native=native,
229
+ headers=auth_headers
230
+ ):
231
+ # 判断是否是Response对象
232
+ if isinstance(chunk, Response):
233
+ status_code = chunk.status_code
234
+ last_error_response = chunk # 记录最后一次错误
235
+
236
+ # 缓存错误解析结果,避免重复decode
237
+ error_body = None
238
+ try:
239
+ error_body = chunk.body.decode('utf-8') if isinstance(chunk.body, bytes) else str(chunk.body)
240
+ except Exception:
241
+ error_body = ""
242
+
243
+ # 如果错误码是429、503或者在禁用码当中,做好记录后进行重试
244
+ if _is_retryable_status(status_code, DISABLE_ERROR_CODES):
245
+ log.warning(f"[ANTIGRAVITY STREAM] 流式请求失败 (status={status_code}), 凭证: {current_file}, 响应: {error_body[:500] if error_body else '无'}")
246
+
247
+ # 解析冷却时间
248
+ cooldown_until = None
249
+ if (status_code == 429 or status_code == 503) and error_body:
250
+ try:
251
+ cooldown_until = await parse_and_log_cooldown(error_body, mode="antigravity")
252
+ except Exception:
253
+ pass
254
+
255
+ # 预热下一个凭证
256
+ if next_cred_task is None and attempt < max_retries:
257
+ next_cred_task = asyncio.create_task(
258
+ credential_manager.get_valid_credential(
259
+ mode="antigravity", model_name=model_name
260
+ )
261
+ )
262
+
263
+ # 记录错误并切换凭证
264
+ await record_api_call_error(
265
+ credential_manager, current_file, status_code,
266
+ cooldown_until, mode="antigravity", model_name=model_name,
267
+ error_message=error_body
268
+ )
269
+
270
+ # 检查是否应该重试
271
+ should_retry = await handle_error_with_retry(
272
+ credential_manager, status_code, current_file,
273
+ retry_config["retry_enabled"], attempt, max_retries, retry_interval,
274
+ mode="antigravity"
275
+ )
276
+
277
+ if should_retry and attempt < max_retries:
278
+ need_retry = True
279
+ break # 跳出内层循环,准备重试
280
+ else:
281
+ # 不重试,直接返回原始错误
282
+ log.error(f"[ANTIGRAVITY STREAM] 达到最大重试次数或不应重试,返回原始错误")
283
+ yield chunk
284
+ return
285
+ else:
286
+ # 错误码不在禁用码当中,直接返回,无需重试
287
+ log.error(f"[ANTIGRAVITY STREAM] 流式请求失败,非重试错误码 (status={status_code}), 凭证: {current_file}, 响应: {error_body[:500] if error_body else '无'}")
288
+ await record_api_call_error(
289
+ credential_manager, current_file, status_code,
290
+ None, mode="antigravity", model_name=model_name,
291
+ error_message=error_body
292
+ )
293
+ yield chunk
294
+ return
295
+ else:
296
+ # 不是Response,说明是真流,直接yield返回
297
+ # 只在第一个chunk时记录成功
298
+ if not success_recorded:
299
+ await record_api_call_success(
300
+ credential_manager, current_file, mode="antigravity", model_name=model_name
301
+ )
302
+ success_recorded = True
303
+ log.debug(f"[ANTIGRAVITY STREAM] 开始接收流式响应,模型: {model_name}")
304
+
305
+ # 记录原始chunk内容(用于调试)
306
+ if isinstance(chunk, bytes):
307
+ log.debug(f"[ANTIGRAVITY STREAM RAW] chunk(bytes): {chunk}")
308
+ else:
309
+ log.debug(f"[ANTIGRAVITY STREAM RAW] chunk(str): {chunk}")
310
+
311
+ yield chunk
312
+
313
+ # 流式请求完成,检查结果
314
+ if success_recorded:
315
+ log.debug(f"[ANTIGRAVITY STREAM] 流式响应完成,模型: {model_name}")
316
+ return
317
+ elif not need_retry:
318
+ # 没有收到任何数据(空回复),需要重试
319
+ log.warning(f"[ANTIGRAVITY STREAM] 收到空回复,无任何内容,凭证: {current_file}")
320
+ await record_api_call_error(
321
+ credential_manager, current_file, 200,
322
+ None, mode="antigravity", model_name=model_name,
323
+ error_message="Empty response from API"
324
+ )
325
+
326
+ if attempt < max_retries:
327
+ need_retry = True
328
+ else:
329
+ log.error(f"[ANTIGRAVITY STREAM] 空回复达到最大重试次数")
330
+ yield Response(
331
+ content=json.dumps({"error": "服务返回空回复"}),
332
+ status_code=500,
333
+ media_type="application/json"
334
+ )
335
+ return
336
+
337
+ # 统一处理重试
338
+ if need_retry:
339
+ log.info(f"[ANTIGRAVITY STREAM] 重试请求 (attempt {attempt + 2}/{max_retries + 1})...")
340
+
341
+ switched, next_cred_task = await _switch_credential_for_retry(
342
+ next_cred_task=next_cred_task,
343
+ retry_interval=retry_interval,
344
+ refresh_credential_fast=refresh_credential_fast,
345
+ apply_cred_result=apply_cred_result,
346
+ log_prefix="[ANTIGRAVITY STREAM]",
347
+ )
348
+ if not switched:
349
+ log.error("[ANTIGRAVITY STREAM] 重试时无可用凭证或令牌")
350
+ yield Response(
351
+ content=json.dumps({"error": "当前无可用凭证"}),
352
+ status_code=500,
353
+ media_type="application/json"
354
+ )
355
+ return
356
+ continue # 重试
357
+
358
+ except Exception as e:
359
+ log.error(f"[ANTIGRAVITY STREAM] 流式请求异常: {e}, 凭证: {current_file}")
360
+ if attempt < max_retries:
361
+ log.info(f"[ANTIGRAVITY STREAM] 异常后重试 (attempt {attempt + 2}/{max_retries + 1})...")
362
+ await asyncio.sleep(retry_interval)
363
+ continue
364
+ else:
365
+ # 所有重试都失败,返回最后一次的错误(如果有)
366
+ log.error(f"[ANTIGRAVITY STREAM] 所有重试均失败,最后异常: {e}")
367
+ if last_error_response:
368
+ yield last_error_response
369
+ else:
370
+ # 如果没有记录到错误响应,返回500错误
371
+ yield Response(
372
+ content=json.dumps({"error": f"流式请求异常: {str(e)}"}),
373
+ status_code=500,
374
+ media_type="application/json"
375
+ )
376
+ return
377
+
378
+ # 所有重试均已耗尽(for循环正常结束),返回最后记录的错误
379
+ log.error("[ANTIGRAVITY STREAM] 所有重试均失败")
380
+ if last_error_response:
381
+ yield last_error_response
382
+ else:
383
+ yield Response(
384
+ content=json.dumps({"error": "请求失败,所有重试均已耗尽"}),
385
+ status_code=429,
386
+ media_type="application/json"
387
+ )
388
+
389
+
390
+ async def non_stream_request(
391
+ body: Dict[str, Any],
392
+ headers: Optional[Dict[str, str]] = None,
393
+ ) -> Response:
394
+ """
395
+ 非流式请求函数
396
+
397
+ Args:
398
+ body: 请求体
399
+ headers: 额外的请求头
400
+
401
+ Returns:
402
+ Response对象
403
+ """
404
+ # 检查是否启用流式收集模式
405
+ if await get_antigravity_stream2nostream():
406
+ log.debug("[ANTIGRAVITY] 使用流式收集模式实现非流式请求")
407
+
408
+ # 调用stream_request获取流
409
+ stream = stream_request(body=body, native=False, headers=headers)
410
+
411
+ # 收集流式响应
412
+ # stream_request是一个异步生成器,可能yield Response(错误)或流数据
413
+ # collect_streaming_response会自动处理这两种情况
414
+ return await collect_streaming_response(stream)
415
+
416
+ # 否则使用传统非流式模式
417
+ log.debug("[ANTIGRAVITY] 使用传统非流式模式")
418
+
419
+ model_name = body.get("model", "")
420
+
421
+ # 1. 获取有效凭证
422
+ cred_result = await credential_manager.get_valid_credential(
423
+ mode="antigravity", model_name=model_name
424
+ )
425
+
426
+ if not cred_result:
427
+ # 如果返回值是None,直接返回错误500
428
+ log.error("[ANTIGRAVITY] 当前无可用凭证")
429
+ return Response(
430
+ content=json.dumps({"error": "当前无可用凭证"}),
431
+ status_code=500,
432
+ media_type="application/json"
433
+ )
434
+
435
+ current_file, credential_data = cred_result
436
+ access_token = credential_data.get("access_token") or credential_data.get("token")
437
+ project_id = credential_data.get("project_id", "")
438
+
439
+ if not access_token:
440
+ log.error(f"[ANTIGRAVITY] No access token in credential: {current_file}")
441
+ return Response(
442
+ content=json.dumps({"error": "凭证中没有访问令牌"}),
443
+ status_code=500,
444
+ media_type="application/json"
445
+ )
446
+
447
+ # 2. 构建URL和请求头
448
+ antigravity_url = await get_code_assist_endpoint()
449
+ target_url = f"{antigravity_url}/v1internal:generateContent"
450
+
451
+ auth_headers = build_antigravity_headers(access_token, model_name)
452
+
453
+ # 合并自定义headers
454
+ if headers:
455
+ auth_headers.update(headers)
456
+
457
+ # 构建包含project的payload
458
+ final_payload = {
459
+ "model": body.get("model"),
460
+ "project": project_id,
461
+ "request": body.get("request", {}),
462
+ }
463
+
464
+ # 仅当凭证明确开启积分消耗时注入 enabledCreditTypes
465
+ def apply_enabled_credit_types(cred_data: Dict[str, Any]) -> None:
466
+ if cred_data.get("enable_credit") is True:
467
+ final_payload["enabledCreditTypes"] = ["GOOGLE_ONE_AI"]
468
+ else:
469
+ final_payload.pop("enabledCreditTypes", None)
470
+
471
+ apply_enabled_credit_types(credential_data)
472
+
473
+ # 3. 调用post_async进行请求
474
+ retry_config = await get_retry_config()
475
+ max_retries = retry_config["max_retries"]
476
+ retry_interval = retry_config["retry_interval"]
477
+
478
+ DISABLE_ERROR_CODES = await get_auto_ban_error_codes() # 禁用凭证的错误码
479
+ last_error_response = None # 记录最后一次的错误响应
480
+ next_cred_task = None # 预热的下一个凭证任务
481
+
482
+ # 内部函数:快速更新凭证(只更新token和project_id,避免重建整个请求)
483
+ async def refresh_credential_fast():
484
+ nonlocal current_file, access_token, auth_headers, project_id, final_payload
485
+ cred_result = await credential_manager.get_valid_credential(
486
+ mode="antigravity", model_name=model_name
487
+ )
488
+ if not cred_result:
489
+ return None
490
+ current_file, credential_data = cred_result
491
+ access_token = credential_data.get("access_token") or credential_data.get("token")
492
+ project_id = credential_data.get("project_id", "")
493
+ if not access_token:
494
+ return None
495
+ # 只更新token和project_id,不重建整个headers和payload
496
+ auth_headers["Authorization"] = f"Bearer {access_token}"
497
+ final_payload["project"] = project_id
498
+ apply_enabled_credit_types(credential_data)
499
+ return True
500
+
501
+ def apply_cred_result(cred_result: Tuple[str, Dict[str, Any]]) -> bool:
502
+ nonlocal current_file, access_token, project_id, auth_headers, final_payload
503
+ current_file, credential_data = cred_result
504
+ access_token = credential_data.get("access_token") or credential_data.get("token")
505
+ project_id = credential_data.get("project_id", "")
506
+ if not access_token or not project_id:
507
+ return False
508
+ auth_headers["Authorization"] = f"Bearer {access_token}"
509
+ final_payload["project"] = project_id
510
+ apply_enabled_credit_types(credential_data)
511
+ return True
512
+
513
+ for attempt in range(max_retries + 1):
514
+ need_retry = False # 标记是否需要重试
515
+
516
+ try:
517
+ response = await post_async(
518
+ url=target_url,
519
+ json=final_payload,
520
+ headers=auth_headers,
521
+ timeout=300.0
522
+ )
523
+
524
+ status_code = response.status_code
525
+
526
+ # 成功
527
+ if status_code == 200:
528
+ # 检查是否为空回复
529
+ if not response.content or len(response.content) == 0:
530
+ log.warning(f"[ANTIGRAVITY] 收到200响应但内容为空,凭证: {current_file}")
531
+
532
+ # 记录错误
533
+ await record_api_call_error(
534
+ credential_manager, current_file, 200,
535
+ None, mode="antigravity", model_name=model_name,
536
+ error_message="Empty response from API"
537
+ )
538
+
539
+ if attempt < max_retries:
540
+ need_retry = True
541
+ else:
542
+ log.error(f"[ANTIGRAVITY] 空回复达到最大重试次数")
543
+ return Response(
544
+ content=json.dumps({"error": "服务返回空回复"}),
545
+ status_code=500,
546
+ media_type="application/json"
547
+ )
548
+ else:
549
+ # 正常响应
550
+ await record_api_call_success(
551
+ credential_manager, current_file, mode="antigravity", model_name=model_name
552
+ )
553
+ return Response(
554
+ content=response.content,
555
+ status_code=200,
556
+ headers=dict(response.headers)
557
+ )
558
+
559
+ # 失败 - 记录最后一次错误
560
+ if status_code != 200:
561
+ last_error_response = Response(
562
+ content=response.content,
563
+ status_code=status_code,
564
+ headers=dict(response.headers)
565
+ )
566
+
567
+ # 判断是否需要重试
568
+ # 缓存错误文本,避免重复解析
569
+ error_text = ""
570
+ try:
571
+ error_text = response.text
572
+ except Exception:
573
+ pass
574
+
575
+ if _is_retryable_status(status_code, DISABLE_ERROR_CODES):
576
+ log.warning(f"[ANTIGRAVITY] 非流式请求失败 (status={status_code}), 凭证: {current_file}, 响应: {error_text[:500] if error_text else '无'}")
577
+
578
+ # 解析冷却时间
579
+ cooldown_until = None
580
+ if (status_code == 429 or status_code == 503) and error_text:
581
+ try:
582
+ cooldown_until = await parse_and_log_cooldown(error_text, mode="antigravity")
583
+ except Exception:
584
+ pass
585
+
586
+ # 并行预热下一个凭证,不阻塞当前处理
587
+ if next_cred_task is None and attempt < max_retries:
588
+ next_cred_task = asyncio.create_task(
589
+ credential_manager.get_valid_credential(
590
+ mode="antigravity", model_name=model_name
591
+ )
592
+ )
593
+
594
+ # 记录错误并切换凭证
595
+ await record_api_call_error(
596
+ credential_manager, current_file, status_code,
597
+ cooldown_until, mode="antigravity", model_name=model_name,
598
+ error_message=error_text
599
+ )
600
+
601
+ # 检查是否应该重试
602
+ should_retry = await handle_error_with_retry(
603
+ credential_manager, status_code, current_file,
604
+ retry_config["retry_enabled"], attempt, max_retries, retry_interval,
605
+ mode="antigravity"
606
+ )
607
+
608
+ if should_retry and attempt < max_retries:
609
+ need_retry = True
610
+ else:
611
+ # 不重试,直接返回原始错误
612
+ log.error(f"[ANTIGRAVITY] 达到最大重试次数或不应重试,返回原始错误")
613
+ return last_error_response
614
+ else:
615
+ # 错误码不在禁用码当中,直接返回,无需重试
616
+ log.error(f"[ANTIGRAVITY] 非流式请求失败,非重试错误码 (status={status_code}), 凭证: {current_file}, 响应: {error_text[:500] if error_text else '无'}")
617
+ await record_api_call_error(
618
+ credential_manager, current_file, status_code,
619
+ None, mode="antigravity", model_name=model_name,
620
+ error_message=error_text
621
+ )
622
+ return last_error_response
623
+
624
+ # 统一处理重试
625
+ if need_retry:
626
+ log.info(f"[ANTIGRAVITY] 重试请求 (attempt {attempt + 2}/{max_retries + 1})...")
627
+
628
+ switched, next_cred_task = await _switch_credential_for_retry(
629
+ next_cred_task=next_cred_task,
630
+ retry_interval=retry_interval,
631
+ refresh_credential_fast=refresh_credential_fast,
632
+ apply_cred_result=apply_cred_result,
633
+ log_prefix="[ANTIGRAVITY]",
634
+ )
635
+ if not switched:
636
+ log.error("[ANTIGRAVITY] 重试时无可用凭证或令牌")
637
+ return Response(
638
+ content=json.dumps({"error": "当前无可用凭证"}),
639
+ status_code=500,
640
+ media_type="application/json"
641
+ )
642
+ continue # 重试
643
+
644
+ except Exception as e:
645
+ log.error(f"[ANTIGRAVITY] 非流式请求异常: {e}, 凭证: {current_file}")
646
+ if attempt < max_retries:
647
+ log.info(f"[ANTIGRAVITY] 异常后重试 (attempt {attempt + 2}/{max_retries + 1})...")
648
+ await asyncio.sleep(retry_interval)
649
+ continue
650
+ else:
651
+ # 所有重试都失败,返回最后一次的错误(如果有)或500错误
652
+ log.error(f"[ANTIGRAVITY] 所有重试均失败,最后异常: {e}")
653
+ if last_error_response:
654
+ return last_error_response
655
+ else:
656
+ return Response(
657
+ content=json.dumps({"error": f"非流式请求异常: {str(e)}"}),
658
+ status_code=500,
659
+ media_type="application/json"
660
+ )
661
+
662
+ # 所有重试都失败,返回最后一次的原始错误(如果有)或500错误
663
+ log.error("[ANTIGRAVITY] 所有重试均失败")
664
+ if last_error_response:
665
+ return last_error_response
666
+ else:
667
+ return Response(
668
+ content=json.dumps({"error": "所有重试均失败"}),
669
+ status_code=500,
670
+ media_type="application/json"
671
+ )
672
+
673
+
674
+ # ==================== 模型和配额查询 ====================
675
+
676
+ async def fetch_available_models() -> List[Dict[str, Any]]:
677
+ """
678
+ 获取可用模型列表,返回符合 OpenAI API 规范的格式
679
+
680
+ Returns:
681
+ 模型列表,格式为字典列表(用于兼容现有代码)
682
+
683
+ Raises:
684
+ 返回空列表如果获取失败
685
+ """
686
+ # 获取凭证管理器和可用凭证
687
+ cred_result = await credential_manager.get_valid_credential(mode="antigravity")
688
+ if not cred_result:
689
+ log.error("[ANTIGRAVITY] No valid credentials available for fetching models")
690
+ return []
691
+
692
+ current_file, credential_data = cred_result
693
+ access_token = credential_data.get("access_token") or credential_data.get("token")
694
+
695
+ if not access_token:
696
+ log.error(f"[ANTIGRAVITY] No access token in credential: {current_file}")
697
+ return []
698
+
699
+ # 构建请求头
700
+ headers = build_antigravity_headers(access_token)
701
+
702
+ try:
703
+ # 使用 POST 请求获取模型列表
704
+ antigravity_url = await get_code_assist_endpoint()
705
+
706
+ response = await post_async(
707
+ url=f"{antigravity_url}/v1internal:fetchAvailableModels",
708
+ json={}, # 空的请求体
709
+ headers=headers
710
+ )
711
+
712
+ if response.status_code == 200:
713
+ data = response.json()
714
+ log.debug(f"[ANTIGRAVITY] Raw models response: {json.dumps(data, ensure_ascii=False)[:500]}")
715
+
716
+ # 转换为 OpenAI 格式的模型列表,使用 Model 类
717
+ model_list = []
718
+ current_timestamp = int(datetime.now(timezone.utc).timestamp())
719
+
720
+ if 'models' in data and isinstance(data['models'], dict):
721
+ # 遍历模型字典
722
+ for model_id in data['models'].keys():
723
+ model = Model(
724
+ id=model_id,
725
+ object='model',
726
+ created=current_timestamp,
727
+ owned_by='google'
728
+ )
729
+ model_list.append(model_to_dict(model))
730
+ # 添加额外的 claude-sonnet-4-6-thinking 模型
731
+ if "claude-sonnet-4-6" in data.get('models', {}):
732
+ model = Model(
733
+ id='claude-sonnet-4-6-thinking',
734
+ object='model',
735
+ created=current_timestamp,
736
+ owned_by='google'
737
+ )
738
+ model_list.append(model_to_dict(model))
739
+ # 添加额外的 claude-opus-4-6 模型
740
+ if "claude-opus-4-6-thinking" in data.get('models', {}):
741
+ claude_opus_model = Model(
742
+ id='claude-opus-4-6',
743
+ object='model',
744
+ created=current_timestamp,
745
+ owned_by='google'
746
+ )
747
+ model_list.append(model_to_dict(claude_opus_model))
748
+
749
+ log.info(f"[ANTIGRAVITY] Fetched {len(model_list)} available models")
750
+ return model_list
751
+ else:
752
+ log.error(f"[ANTIGRAVITY] Failed to fetch models ({response.status_code}): {response.text[:500]}")
753
+ return []
754
+
755
+ except Exception as e:
756
+ import traceback
757
+ log.error(f"[ANTIGRAVITY] Failed to fetch models: {e}")
758
+ log.error(f"[ANTIGRAVITY] Traceback: {traceback.format_exc()}")
759
+ return []
760
+
761
+
762
+ async def fetch_quota_info(access_token: str) -> Dict[str, Any]:
763
+ """
764
+ 获取指定凭证的额度信息
765
+
766
+ Args:
767
+ access_token: Antigravity 访问令牌
768
+
769
+ Returns:
770
+ 包含额度信息的字典,格式为:
771
+ {
772
+ "success": True/False,
773
+ "models": {
774
+ "model_name": {
775
+ "remaining": 0.95,
776
+ "resetTime": "12-20 10:30",
777
+ "resetTimeRaw": "2025-12-20T02:30:00Z"
778
+ }
779
+ },
780
+ "error": "错误信息" (仅在失败时)
781
+ }
782
+ """
783
+
784
+ headers = build_antigravity_headers(access_token)
785
+
786
+ try:
787
+ antigravity_url = await get_code_assist_endpoint()
788
+
789
+ response = await post_async(
790
+ url=f"{antigravity_url}/v1internal:fetchAvailableModels",
791
+ json={},
792
+ headers=headers,
793
+ timeout=30.0
794
+ )
795
+
796
+ if response.status_code == 200:
797
+ data = response.json()
798
+ log.debug(f"[ANTIGRAVITY QUOTA] Raw response: {json.dumps(data, ensure_ascii=False)[:500]}")
799
+
800
+ quota_info = {}
801
+
802
+ if 'models' in data and isinstance(data['models'], dict):
803
+ for model_id, model_data in data['models'].items():
804
+ if isinstance(model_data, dict) and 'quotaInfo' in model_data:
805
+ quota = model_data['quotaInfo']
806
+ remaining = quota.get('remainingFraction', 0)
807
+ reset_time_raw = quota.get('resetTime', '')
808
+
809
+ # 转换为北京时间
810
+ reset_time_beijing = 'N/A'
811
+ if reset_time_raw:
812
+ try:
813
+ utc_date = datetime.fromisoformat(reset_time_raw.replace('Z', '+00:00'))
814
+ # 转换为北京时间 (UTC+8)
815
+ from datetime import timedelta
816
+ beijing_date = utc_date + timedelta(hours=8)
817
+ reset_time_beijing = beijing_date.strftime('%m-%d %H:%M')
818
+ except Exception as e:
819
+ log.warning(f"[ANTIGRAVITY QUOTA] Failed to parse reset time: {e}")
820
+
821
+ quota_info[model_id] = {
822
+ "remaining": remaining,
823
+ "resetTime": reset_time_beijing,
824
+ "resetTimeRaw": reset_time_raw
825
+ }
826
+
827
+ return {
828
+ "success": True,
829
+ "models": quota_info
830
+ }
831
+ else:
832
+ log.error(f"[ANTIGRAVITY QUOTA] Failed to fetch quota ({response.status_code}): {response.text[:500]}")
833
+ return {
834
+ "success": False,
835
+ "error": f"API返回错误: {response.status_code}"
836
+ }
837
+
838
+ except Exception as e:
839
+ import traceback
840
+ log.error(f"[ANTIGRAVITY QUOTA] Failed to fetch quota: {e}")
841
+ log.error(f"[ANTIGRAVITY QUOTA] Traceback: {traceback.format_exc()}")
842
+ return {
843
+ "success": False,
844
+ "error": str(e)
845
+ }
src/api/geminicli.py ADDED
@@ -0,0 +1,808 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ GeminiCli API Client - Handles all communication with GeminiCli API.
3
+ This module is used by both OpenAI compatibility layer and native Gemini endpoints.
4
+ GeminiCli API 客户端 - 处理与 GeminiCli API 的所有通信
5
+ """
6
+
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ # 添加项目根目录到Python路径(用于直接运行测试)
11
+ if __name__ == "__main__":
12
+ project_root = Path(__file__).resolve().parent.parent.parent
13
+ if str(project_root) not in sys.path:
14
+ sys.path.insert(0, str(project_root))
15
+
16
+ import asyncio
17
+ import json
18
+ from typing import Any, Dict, Optional, Callable, Tuple
19
+
20
+ from fastapi import Response
21
+ from config import get_code_assist_endpoint, get_auto_ban_error_codes
22
+ from log import log
23
+
24
+ from src.credential_manager import credential_manager
25
+ from src.httpx_client import stream_post_async, post_async
26
+
27
+ # 导入共同的基础功能
28
+ from src.api.utils import (
29
+ handle_error_with_retry,
30
+ get_retry_config,
31
+ record_api_call_success,
32
+ record_api_call_error,
33
+ parse_and_log_cooldown,
34
+ )
35
+ from src.utils import get_geminicli_user_agent
36
+
37
+ # ==================== 全局凭证管理器 ====================
38
+
39
+ # 使用全局单例 credential_manager,自动初始化
40
+
41
+
42
+ # ==================== 请求准备 ====================
43
+
44
+ async def prepare_request_headers_and_payload(
45
+ payload: dict, credential_data: dict, target_url: str
46
+ ):
47
+ """
48
+ 从凭证数据准备请求头和最终payload
49
+
50
+ Args:
51
+ payload: 原始请求payload
52
+ credential_data: 凭证数据字典
53
+ target_url: 目标URL
54
+
55
+ Returns:
56
+ 元组: (headers, final_payload, target_url)
57
+
58
+ Raises:
59
+ Exception: 如果凭证中缺少必要字段
60
+ """
61
+ token = credential_data.get("token") or credential_data.get("access_token", "")
62
+ if not token:
63
+ raise Exception("凭证中没有找到有效的访问令牌(token或access_token字段)")
64
+
65
+ source_request = payload.get("request", {})
66
+
67
+ # 内部API使用Bearer Token和项目ID
68
+ headers = {
69
+ "Authorization": f"Bearer {token}",
70
+ "Content-Type": "application/json",
71
+ "User-Agent": get_geminicli_user_agent(payload.get("model", "")),
72
+ }
73
+ project_id = credential_data.get("project_id", "")
74
+ if not project_id:
75
+ raise Exception("项目ID不存在于凭证数据中")
76
+ final_payload = {
77
+ "model": payload.get("model"),
78
+ "project": project_id,
79
+ "request": source_request,
80
+ }
81
+
82
+ return headers, final_payload, target_url
83
+
84
+
85
+ def _is_retryable_status(status_code: int, disable_error_codes: list[int]) -> bool:
86
+ """统一判断是否属于可重试状态码。"""
87
+ return status_code in (429, 503) or status_code in disable_error_codes
88
+
89
+
90
+ async def _switch_credential_for_retry(
91
+ *,
92
+ next_cred_task: Optional[asyncio.Task],
93
+ retry_interval: float,
94
+ refresh_credential_fast: Callable[[], Any],
95
+ apply_cred_result: Callable[[Tuple[str, Dict[str, Any]]], bool],
96
+ log_prefix: str,
97
+ ) -> Tuple[bool, Optional[asyncio.Task]]:
98
+ """优先使用预热凭证,失败后退回同步刷新。"""
99
+ if next_cred_task is not None:
100
+ try:
101
+ cred_result = await next_cred_task
102
+ next_cred_task = None
103
+ if cred_result and apply_cred_result(cred_result):
104
+ await asyncio.sleep(retry_interval)
105
+ return True, next_cred_task
106
+ except Exception as e:
107
+ log.warning(f"{log_prefix} 预热凭证任务失败: {e}")
108
+ next_cred_task = None
109
+
110
+ await asyncio.sleep(retry_interval)
111
+ if await refresh_credential_fast():
112
+ return True, next_cred_task
113
+
114
+ return False, next_cred_task
115
+
116
+
117
+ # ==================== 新的流式和非流式请求函数 ====================
118
+
119
+ async def stream_request(
120
+ body: Dict[str, Any],
121
+ native: bool = False,
122
+ headers: Optional[Dict[str, str]] = None,
123
+ ):
124
+ """
125
+ 流式请求函数
126
+
127
+ Args:
128
+ body: 请求体
129
+ native: 是否返回原生bytes流,False则返回str流
130
+ headers: 额外的请求头
131
+
132
+ Yields:
133
+ Response对象(错误时)或 bytes流/str流(成功时)
134
+ """
135
+ # 获取有效凭证
136
+ model_name = body.get("model", "")
137
+
138
+ # 1. 获取有效凭证
139
+ cred_result = await credential_manager.get_valid_credential(
140
+ mode="geminicli", model_name=model_name
141
+ )
142
+
143
+ if not cred_result:
144
+ # 如果返回值是None,直接返回错误500
145
+ yield Response(
146
+ content=json.dumps({"error": "当前无可用凭证"}),
147
+ status_code=500,
148
+ media_type="application/json"
149
+ )
150
+ return
151
+
152
+ current_file, credential_data = cred_result
153
+
154
+ # 2. 构建URL和请求头
155
+ try:
156
+ auth_headers, final_payload, target_url = await prepare_request_headers_and_payload(
157
+ body, credential_data,
158
+ f"{await get_code_assist_endpoint()}/v1internal:streamGenerateContent?alt=sse"
159
+ )
160
+
161
+ # 合并自定义headers
162
+ if headers:
163
+ auth_headers.update(headers)
164
+
165
+ except Exception as e:
166
+ log.error(f"准备请求失败: {e}")
167
+ yield Response(
168
+ content=json.dumps({"error": f"准备请求失败: {str(e)}"}),
169
+ status_code=500,
170
+ media_type="application/json"
171
+ )
172
+ return
173
+
174
+ # 3. 调用stream_post_async进行请求
175
+ retry_config = await get_retry_config()
176
+ max_retries = retry_config["max_retries"]
177
+ retry_interval = retry_config["retry_interval"]
178
+
179
+ DISABLE_ERROR_CODES = await get_auto_ban_error_codes() # 禁用凭证的错误码
180
+ last_error_response = None # 记录最后一次的错误响应
181
+ next_cred_task = None # 预热的下一个凭证任务
182
+
183
+ # 内部函数:快速更新凭证(只更新token和project_id,避免重建整个请求)
184
+ async def refresh_credential_fast():
185
+ nonlocal current_file, credential_data, auth_headers, final_payload
186
+ cred_result = await credential_manager.get_valid_credential(
187
+ mode="geminicli", model_name=model_name
188
+ )
189
+ if not cred_result:
190
+ return None
191
+ current_file, credential_data = cred_result
192
+ try:
193
+ # 只更新token和project_id,不重建整个headers和payload
194
+ token = credential_data.get("token") or credential_data.get("access_token", "")
195
+ project_id = credential_data.get("project_id", "")
196
+ if not token or not project_id:
197
+ return None
198
+
199
+ # 直接更新现有的headers和payload
200
+ auth_headers["Authorization"] = f"Bearer {token}"
201
+ final_payload["project"] = project_id
202
+ return True
203
+ except Exception:
204
+ return None
205
+
206
+ def apply_cred_result(cred_result: Tuple[str, Dict[str, Any]]) -> bool:
207
+ nonlocal current_file, credential_data, auth_headers, final_payload
208
+ current_file, credential_data = cred_result
209
+ token = credential_data.get("token") or credential_data.get("access_token", "")
210
+ project_id = credential_data.get("project_id", "")
211
+ if not token or not project_id:
212
+ return False
213
+ auth_headers["Authorization"] = f"Bearer {token}"
214
+ final_payload["project"] = project_id
215
+ return True
216
+
217
+ for attempt in range(max_retries + 1):
218
+ success_recorded = False # 标记是否已记录成功
219
+ need_retry = False # 标记是否需要重试
220
+
221
+ try:
222
+ async for chunk in stream_post_async(
223
+ url=target_url,
224
+ body=final_payload,
225
+ native=native,
226
+ headers=auth_headers
227
+ ):
228
+ # 判断是否是Response对象
229
+ if isinstance(chunk, Response):
230
+ status_code = chunk.status_code
231
+ last_error_response = chunk # 记录最后一次错误
232
+
233
+ # 缓存错误解析结果,避免重复decode
234
+ error_body = None
235
+ try:
236
+ error_body = chunk.body.decode('utf-8') if isinstance(chunk.body, bytes) else str(chunk.body)
237
+ except Exception:
238
+ error_body = ""
239
+
240
+ # 如果错误码是429、503或者在禁用码当中,做好记录后进行重试
241
+ if _is_retryable_status(status_code, DISABLE_ERROR_CODES):
242
+ log.warning(f"[GEMINICLI STREAM] 流式请求失败 (status={status_code}), 凭证: {current_file}, 响应: {error_body[:500] if error_body else '无'}")
243
+
244
+ # 解析冷却时间
245
+ cooldown_until = None
246
+ if (status_code == 429 or status_code == 503) and error_body:
247
+ try:
248
+ cooldown_until = await parse_and_log_cooldown(error_body, mode="geminicli")
249
+ except Exception:
250
+ pass
251
+
252
+ # 预热下一个凭证
253
+ if next_cred_task is None and attempt < max_retries:
254
+ next_cred_task = asyncio.create_task(
255
+ credential_manager.get_valid_credential(
256
+ mode="geminicli", model_name=model_name
257
+ )
258
+ )
259
+
260
+ # 记录错误并切换凭证
261
+ await record_api_call_error(
262
+ credential_manager, current_file, status_code,
263
+ cooldown_until, mode="geminicli", model_name=model_name,
264
+ error_message=error_body
265
+ )
266
+
267
+ # 检查是否应该重试
268
+ should_retry = await handle_error_with_retry(
269
+ credential_manager, status_code, current_file,
270
+ retry_config["retry_enabled"], attempt, max_retries, retry_interval,
271
+ mode="geminicli"
272
+ )
273
+
274
+ if should_retry and attempt < max_retries:
275
+ need_retry = True
276
+ break # 跳出内层循环,准备重试
277
+ else:
278
+ # 不重试,直接返回原始错误
279
+ log.error(f"[GEMINICLI STREAM] 达到最大重试次数或不应重试,返回原始错误")
280
+ yield chunk
281
+ return
282
+ elif status_code == 404 and "preview" in model_name.lower():
283
+ # 特殊处理:preview模型返回404,说明该凭证不支持preview模型
284
+ log.warning(f"[GEMINICLI STREAM] Preview模型404错误,凭证不支持preview: {current_file}")
285
+
286
+ # 将该凭证的preview状态设置为False
287
+ try:
288
+ await credential_manager.update_credential_state(
289
+ current_file, {"preview": False}, mode="geminicli"
290
+ )
291
+ log.info(f"[GEMINICLI STREAM] 已将凭证 {current_file} 的preview状态设置为False")
292
+ except Exception as e:
293
+ log.error(f"[GEMINICLI STREAM] 更新凭证preview状态失败: {e}")
294
+
295
+ # 记录404错误
296
+ await record_api_call_error(
297
+ credential_manager, current_file, status_code,
298
+ None, mode="geminicli", model_name=model_name,
299
+ error_message=error_body
300
+ )
301
+
302
+ # 预热下一个凭证(会自动跳过preview=False的凭证)
303
+ if next_cred_task is None and attempt < max_retries:
304
+ next_cred_task = asyncio.create_task(
305
+ credential_manager.get_valid_credential(
306
+ mode="geminicli", model_name=model_name
307
+ )
308
+ )
309
+
310
+ # 触发重试
311
+ if attempt < max_retries:
312
+ need_retry = True
313
+ break
314
+ else:
315
+ log.error(f"[GEMINICLI STREAM] 达到最大重试次数,返回404错误")
316
+ yield chunk
317
+ return
318
+ else:
319
+ # 错误码不在禁用码当中,直接返回,无需重试
320
+ log.error(f"[GEMINICLI STREAM] 流式请求失败,非重试错误码 (status={status_code}), 凭证: {current_file}, 响应: {error_body[:500] if error_body else '无'}")
321
+ await record_api_call_error(
322
+ credential_manager, current_file, status_code,
323
+ None, mode="geminicli", model_name=model_name,
324
+ error_message=error_body
325
+ )
326
+ yield chunk
327
+ return
328
+ else:
329
+ # 不是Response,说明是真流,直接yield返回
330
+ # 只在第一个chunk时记录成功
331
+ if not success_recorded:
332
+ await record_api_call_success(
333
+ credential_manager, current_file, mode="geminicli", model_name=model_name
334
+ )
335
+ success_recorded = True
336
+ log.debug(f"[GEMINICLI STREAM] 开始接收流式响应,模型: {model_name}")
337
+
338
+ yield chunk
339
+
340
+ # 流式请求完成,检查结果
341
+ if success_recorded:
342
+ log.debug(f"[GEMINICLI STREAM] 流式响应完成,模型: {model_name}")
343
+ return
344
+
345
+ # 统一处理重试
346
+ if need_retry:
347
+ # 如果已经是最后一次尝试,不再重试,直接返回错误
348
+ if attempt >= max_retries:
349
+ log.error(f"[GEMINICLI STREAM] 达到最大重试次数,返回错误")
350
+ if last_error_response:
351
+ yield last_error_response
352
+ else:
353
+ yield Response(
354
+ content=json.dumps({"error": "请求失败,所有重试均已耗尽"}),
355
+ status_code=429,
356
+ media_type="application/json"
357
+ )
358
+ return
359
+
360
+ log.info(f"[GEMINICLI STREAM] 重试请求 (attempt {attempt + 2}/{max_retries + 1})...")
361
+
362
+ switched, next_cred_task = await _switch_credential_for_retry(
363
+ next_cred_task=next_cred_task,
364
+ retry_interval=retry_interval,
365
+ refresh_credential_fast=refresh_credential_fast,
366
+ apply_cred_result=apply_cred_result,
367
+ log_prefix="[GEMINICLI STREAM]",
368
+ )
369
+ if not switched:
370
+ log.error("[GEMINICLI STREAM] 重试时无可用凭证或刷新失败")
371
+ yield Response(
372
+ content=json.dumps({"error": "当前无可用凭证"}),
373
+ status_code=500,
374
+ media_type="application/json"
375
+ )
376
+ return
377
+ continue # 重试
378
+
379
+ except Exception as e:
380
+ log.error(f"[GEMINICLI STREAM] 流式请求异常: {e}, 凭证: {current_file}")
381
+ if attempt < max_retries:
382
+ log.info(f"[GEMINICLI STREAM] 异常后重试 (attempt {attempt + 2}/{max_retries + 1})...")
383
+ await asyncio.sleep(retry_interval)
384
+ continue
385
+ else:
386
+ # 所有重试都失败,返回最后一次的错误(如果有)
387
+ log.error(f"[GEMINICLI STREAM] 所有重试均失败,最后异常: {e}")
388
+ if last_error_response:
389
+ yield last_error_response
390
+ else:
391
+ # 如果没有记录到错误响应,返回500错误
392
+ yield Response(
393
+ content=json.dumps({"error": f"流式请求异常: {str(e)}"}),
394
+ status_code=500,
395
+ media_type="application/json"
396
+ )
397
+ return
398
+
399
+ # 所有重试均已耗尽(for循环正常结束),返回最后记录的错误
400
+ log.error("[GEMINICLI STREAM] 所有重试均失败")
401
+ if last_error_response:
402
+ yield last_error_response
403
+ else:
404
+ yield Response(
405
+ content=json.dumps({"error": "请求失败,所有重试均已耗尽"}),
406
+ status_code=429,
407
+ media_type="application/json"
408
+ )
409
+
410
+
411
+ async def non_stream_request(
412
+ body: Dict[str, Any],
413
+ headers: Optional[Dict[str, str]] = None,
414
+ ) -> Response:
415
+ """
416
+ 非流式请求函数
417
+
418
+ Args:
419
+ body: 请求体
420
+ native: 保留参数以保持接口一致性(实际未使用)
421
+ headers: 额外的请求头
422
+
423
+ Returns:
424
+ Response对象
425
+ """
426
+ # 获取有效凭证
427
+ model_name = body.get("model", "")
428
+
429
+ # 1. 获取有效凭证
430
+ cred_result = await credential_manager.get_valid_credential(
431
+ mode="geminicli", model_name=model_name
432
+ )
433
+
434
+ if not cred_result:
435
+ # 如果返回值是None,直接返回错误500
436
+ return Response(
437
+ content=json.dumps({"error": "当前无可用凭证"}),
438
+ status_code=500,
439
+ media_type="application/json"
440
+ )
441
+
442
+ current_file, credential_data = cred_result
443
+
444
+ # 2. 构建URL和请求头
445
+ try:
446
+ auth_headers, final_payload, target_url = await prepare_request_headers_and_payload(
447
+ body, credential_data,
448
+ f"{await get_code_assist_endpoint()}/v1internal:generateContent"
449
+ )
450
+
451
+ # 合并自定义headers
452
+ if headers:
453
+ auth_headers.update(headers)
454
+
455
+ except Exception as e:
456
+ log.error(f"准备请求失败: {e}")
457
+ return Response(
458
+ content=json.dumps({"error": f"准备请求失败: {str(e)}"}),
459
+ status_code=500,
460
+ media_type="application/json"
461
+ )
462
+
463
+ # 3. 调用post_async进行请求
464
+ retry_config = await get_retry_config()
465
+ max_retries = retry_config["max_retries"]
466
+ retry_interval = retry_config["retry_interval"]
467
+
468
+ DISABLE_ERROR_CODES = await get_auto_ban_error_codes() # 禁用凭证的错误码
469
+ last_error_response = None # 记录最后一次的错误响应
470
+ next_cred_task = None # 预热的下一个凭证任务
471
+
472
+ # 内部函数:快速更新凭证(只更新token和project_id,避免重建整个请求)
473
+ async def refresh_credential_fast():
474
+ nonlocal current_file, credential_data, auth_headers, final_payload
475
+ cred_result = await credential_manager.get_valid_credential(
476
+ mode="geminicli", model_name=model_name
477
+ )
478
+ if not cred_result:
479
+ return None
480
+ current_file, credential_data = cred_result
481
+ try:
482
+ # 只更新token和project_id,不重建整个headers和payload
483
+ token = credential_data.get("token") or credential_data.get("access_token", "")
484
+ project_id = credential_data.get("project_id", "")
485
+ if not token or not project_id:
486
+ return None
487
+
488
+ # 直接更新现有的headers和payload
489
+ auth_headers["Authorization"] = f"Bearer {token}"
490
+ final_payload["project"] = project_id
491
+ return True
492
+ except Exception:
493
+ return None
494
+
495
+ def apply_cred_result(cred_result: Tuple[str, Dict[str, Any]]) -> bool:
496
+ nonlocal current_file, credential_data, auth_headers, final_payload
497
+ current_file, credential_data = cred_result
498
+ token = credential_data.get("token") or credential_data.get("access_token", "")
499
+ project_id = credential_data.get("project_id", "")
500
+ if not token or not project_id:
501
+ return False
502
+ auth_headers["Authorization"] = f"Bearer {token}"
503
+ final_payload["project"] = project_id
504
+ return True
505
+
506
+ for attempt in range(max_retries + 1):
507
+ try:
508
+ response = await post_async(
509
+ url=target_url,
510
+ json=final_payload,
511
+ headers=auth_headers,
512
+ timeout=300.0
513
+ )
514
+
515
+ status_code = response.status_code
516
+
517
+ # 成功
518
+ if status_code == 200:
519
+ await record_api_call_success(
520
+ credential_manager, current_file, mode="geminicli", model_name=model_name
521
+ )
522
+ # 创建响应头,移除压缩相关的header避免重复解压
523
+ response_headers = dict(response.headers)
524
+ response_headers.pop('content-encoding', None)
525
+ response_headers.pop('content-length', None)
526
+
527
+ return Response(
528
+ content=response.content,
529
+ status_code=200,
530
+ headers=response_headers
531
+ )
532
+
533
+ # 失败 - 记录最后一次错误
534
+ # 创建响应头,移除压缩相关的header避免重复解压
535
+ error_headers = dict(response.headers)
536
+ error_headers.pop('content-encoding', None)
537
+ error_headers.pop('content-length', None)
538
+
539
+ last_error_response = Response(
540
+ content=response.content,
541
+ status_code=status_code,
542
+ headers=error_headers
543
+ )
544
+
545
+ # 判断是否需要重试
546
+ # 缓存错误文本,避免重复解析
547
+ error_text = ""
548
+ try:
549
+ error_text = response.text
550
+ except Exception:
551
+ pass
552
+
553
+ # 统一处理所有需要重试的错误码(429、503、禁用码)
554
+ if _is_retryable_status(status_code, DISABLE_ERROR_CODES):
555
+ log.warning(f"[NON-STREAM] 非流式请求失败 (status={status_code}), 凭证: {current_file}, 响应: {error_text[:500] if error_text else '无'}")
556
+
557
+ # 解析冷却时间
558
+ cooldown_until = None
559
+ if (status_code == 429 or status_code == 503) and error_text:
560
+ try:
561
+ cooldown_until = await parse_and_log_cooldown(error_text, mode="geminicli")
562
+ except Exception:
563
+ pass
564
+
565
+ # 并行预热下一个凭证,不阻塞当前处理
566
+ if next_cred_task is None and attempt < max_retries:
567
+ next_cred_task = asyncio.create_task(
568
+ credential_manager.get_valid_credential(
569
+ mode="geminicli", model_name=model_name
570
+ )
571
+ )
572
+
573
+ # 记录错误并切换凭证
574
+ await record_api_call_error(
575
+ credential_manager, current_file, status_code,
576
+ cooldown_until, mode="geminicli", model_name=model_name,
577
+ error_message=error_text
578
+ )
579
+
580
+ # 检查是否应该重试(会自动处理禁用逻辑)
581
+ should_retry = await handle_error_with_retry(
582
+ credential_manager, status_code, current_file,
583
+ retry_config["retry_enabled"], attempt, max_retries, retry_interval,
584
+ mode="geminicli"
585
+ )
586
+
587
+ if should_retry and attempt < max_retries:
588
+ # 重新获取凭证并重试
589
+ log.info(f"[NON-STREAM] 重试请求 (attempt {attempt + 2}/{max_retries + 1})...")
590
+
591
+ switched, next_cred_task = await _switch_credential_for_retry(
592
+ next_cred_task=next_cred_task,
593
+ retry_interval=retry_interval,
594
+ refresh_credential_fast=refresh_credential_fast,
595
+ apply_cred_result=apply_cred_result,
596
+ log_prefix="[NON-STREAM]",
597
+ )
598
+ if not switched:
599
+ log.error("[NON-STREAM] 重试时无可用凭证或刷新失败")
600
+ return Response(
601
+ content=json.dumps({"error": "当前无可用凭证"}),
602
+ status_code=500,
603
+ media_type="application/json"
604
+ )
605
+ continue # 重试
606
+ else:
607
+ # 不重试,直接返回原始错误
608
+ log.error(f"[NON-STREAM] 达到最大重试次数或不应重试,返回原始错误")
609
+ return last_error_response
610
+ elif status_code == 404 and "preview" in model_name.lower():
611
+ # 特殊处理:preview模型返回404,说明该凭证不支持preview模型
612
+ log.warning(f"[NON-STREAM] Preview模型404错误,凭证不支持preview: {current_file}")
613
+
614
+ # 将该凭证的preview状态设置为False
615
+ try:
616
+ await credential_manager.update_credential_state(
617
+ current_file, {"preview": False}, mode="geminicli"
618
+ )
619
+ log.info(f"[NON-STREAM] 已将凭证 {current_file} 的preview状态设置为False")
620
+ except Exception as e:
621
+ log.error(f"[NON-STREAM] 更新凭证preview状态失败: {e}")
622
+
623
+ # 记录404错误
624
+ await record_api_call_error(
625
+ credential_manager, current_file, status_code,
626
+ None, mode="geminicli", model_name=model_name,
627
+ error_message=error_text
628
+ )
629
+
630
+ # 预热下一个凭证(会自动跳过preview=False的凭证)
631
+ if next_cred_task is None and attempt < max_retries:
632
+ next_cred_task = asyncio.create_task(
633
+ credential_manager.get_valid_credential(
634
+ mode="geminicli", model_name=model_name
635
+ )
636
+ )
637
+
638
+ # 触发重试
639
+ if attempt < max_retries:
640
+ log.info(f"[NON-STREAM] 重试请求 (attempt {attempt + 2}/{max_retries + 1})...")
641
+
642
+ switched, next_cred_task = await _switch_credential_for_retry(
643
+ next_cred_task=next_cred_task,
644
+ retry_interval=retry_interval,
645
+ refresh_credential_fast=refresh_credential_fast,
646
+ apply_cred_result=apply_cred_result,
647
+ log_prefix="[NON-STREAM]",
648
+ )
649
+ if not switched:
650
+ log.error("[NON-STREAM] 重试时无可用凭证或刷新失败")
651
+ return Response(
652
+ content=json.dumps({"error": "当前无可用凭证"}),
653
+ status_code=500,
654
+ media_type="application/json"
655
+ )
656
+ continue # 重试
657
+ else:
658
+ log.error(f"[NON-STREAM] 达到最大重试次数,返回404错误")
659
+ return last_error_response
660
+ else:
661
+ # 错误码不在重试范围内,直接返回
662
+ log.error(f"[NON-STREAM] 非流式请求失败,非重试错误码 (status={status_code}), 凭证: {current_file}, 响应: {error_text[:500] if error_text else '无'}")
663
+ await record_api_call_error(
664
+ credential_manager, current_file, status_code,
665
+ None, mode="geminicli", model_name=model_name,
666
+ error_message=error_text
667
+ )
668
+ return last_error_response
669
+
670
+ except Exception as e:
671
+ log.error(f"非流式请求异常: {e}, 凭证: {current_file}")
672
+ if attempt < max_retries:
673
+ log.info(f"[NON-STREAM] 异常后重试 (attempt {attempt + 2}/{max_retries + 1})...")
674
+ await asyncio.sleep(retry_interval)
675
+ continue
676
+ else:
677
+ # 所有重试都失败,返回最后一次的错误(如果有)或500错误
678
+ log.error(f"[NON-STREAM] 所有重试均失败,最后异常: {e}")
679
+ if last_error_response:
680
+ return last_error_response
681
+ else:
682
+ return Response(
683
+ content=json.dumps({"error": f"请求异常: {str(e)}"}),
684
+ status_code=500,
685
+ media_type="application/json"
686
+ )
687
+
688
+ # 所有重试都失败,返回最后一次的原始错误
689
+ log.error("[NON-STREAM] 所有重试均失败")
690
+ return last_error_response
691
+
692
+
693
+ # ==================== 测试代码 ====================
694
+
695
+ if __name__ == "__main__":
696
+ """
697
+ 测试代码:演示API返回的流式和非流式数据格式
698
+ 运行方式: python src/api/geminicli.py
699
+ """
700
+ print("=" * 80)
701
+ print("GeminiCli API 测试")
702
+ print("=" * 80)
703
+
704
+ # 测试请求体
705
+ test_body = {
706
+ "model": "gemini-2.5-flash",
707
+ "request": {
708
+ "contents": [
709
+ {
710
+ "role": "user",
711
+ "parts": [{"text": "Hello, tell me a joke in one sentence."}]
712
+ }
713
+ ]
714
+ }
715
+ }
716
+
717
+ async def test_stream_request():
718
+ """测试流式请求"""
719
+ print("\n" + "=" * 80)
720
+ print("【测试1】流式请求 (stream_request with native=False)")
721
+ print("=" * 80)
722
+ print(f"请求体: {json.dumps(test_body, indent=2, ensure_ascii=False)}\n")
723
+
724
+ print("流式响应数据 (每个chunk):")
725
+ print("-" * 80)
726
+
727
+ chunk_count = 0
728
+ async for chunk in stream_request(body=test_body, native=False):
729
+ chunk_count += 1
730
+ if isinstance(chunk, Response):
731
+ # 错误响应
732
+ print(f"\n❌ 错误响应:")
733
+ print(f" 状态码: {chunk.status_code}")
734
+ print(f" Content-Type: {chunk.headers.get('content-type', 'N/A')}")
735
+ try:
736
+ content = chunk.body.decode('utf-8') if isinstance(chunk.body, bytes) else str(chunk.body)
737
+ print(f" 内容: {content}")
738
+ except Exception as e:
739
+ print(f" 内容解析失败: {e}")
740
+ else:
741
+ # 正常的流式数据块 (str类型)
742
+ print(f"\nChunk #{chunk_count}:")
743
+ print(f" 类型: {type(chunk).__name__}")
744
+ print(f" 长度: {len(chunk) if hasattr(chunk, '__len__') else 'N/A'}")
745
+ print(f" 内容预览: {repr(chunk[:200] if len(chunk) > 200 else chunk)}")
746
+
747
+ # 如果是SSE格式,尝试解析
748
+ if isinstance(chunk, str) and chunk.startswith("data: "):
749
+ try:
750
+ data_line = chunk.strip()
751
+ if data_line.startswith("data: "):
752
+ json_str = data_line[6:] # 去掉 "data: " 前缀
753
+ json_data = json.loads(json_str)
754
+ print(f" 解析后的JSON: {json.dumps(json_data, indent=4, ensure_ascii=False)}")
755
+ except Exception as e:
756
+ print(f" SSE解析尝试失败: {e}")
757
+
758
+ print(f"\n总共收到 {chunk_count} 个chunk")
759
+
760
+ async def test_non_stream_request():
761
+ """测试非流式请求"""
762
+ print("\n" + "=" * 80)
763
+ print("【测试2】非流式请求 (non_stream_request)")
764
+ print("=" * 80)
765
+ print(f"请求体: {json.dumps(test_body, indent=2, ensure_ascii=False)}\n")
766
+
767
+ response = await non_stream_request(body=test_body)
768
+
769
+ print("非流式响应数据:")
770
+ print("-" * 80)
771
+ print(f"状态码: {response.status_code}")
772
+ print(f"Content-Type: {response.headers.get('content-type', 'N/A')}")
773
+ print(f"\n响应头: {dict(response.headers)}\n")
774
+
775
+ try:
776
+ content = response.body.decode('utf-8') if isinstance(response.body, bytes) else str(response.body)
777
+ print(f"响应内容 (原始):\n{content}\n")
778
+
779
+ # 尝试解析JSON
780
+ try:
781
+ json_data = json.loads(content)
782
+ print(f"响应内容 (格式化JSON):")
783
+ print(json.dumps(json_data, indent=2, ensure_ascii=False))
784
+ except json.JSONDecodeError:
785
+ print("(非JSON格式)")
786
+ except Exception as e:
787
+ print(f"内容解析失败: {e}")
788
+
789
+ async def main():
790
+ """主测试函数"""
791
+ try:
792
+ # 测试流式请求
793
+ await test_stream_request()
794
+
795
+ # 测试非流式请求
796
+ await test_non_stream_request()
797
+
798
+ print("\n" + "=" * 80)
799
+ print("测试完成")
800
+ print("=" * 80)
801
+
802
+ except Exception as e:
803
+ print(f"\n❌ 测试过程中出现异常: {e}")
804
+ import traceback
805
+ traceback.print_exc()
806
+
807
+ # 运行测试
808
+ asyncio.run(main())
src/api/utils.py ADDED
@@ -0,0 +1,505 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Base API Client - 共用的 API 客户端基础功能
3
+ 提供错误处理、自动封禁、重试逻辑等共同功能
4
+ """
5
+
6
+ import asyncio
7
+ import json
8
+ from datetime import datetime, timezone
9
+ from typing import Any, Dict, Optional
10
+
11
+ from fastapi import Response
12
+
13
+ from config import (
14
+ get_auto_ban_enabled,
15
+ get_auto_ban_error_codes,
16
+ get_retry_429_enabled,
17
+ get_retry_429_interval,
18
+ get_retry_429_max_retries,
19
+ )
20
+ from log import log
21
+ from src.credential_manager import CredentialManager
22
+
23
+
24
+ # ==================== 错误检查与处理 ====================
25
+
26
+ async def check_should_auto_ban(status_code: int) -> bool:
27
+ """
28
+ 检查是否应该触发自动封禁
29
+
30
+ Args:
31
+ status_code: HTTP状态码
32
+
33
+ Returns:
34
+ bool: 是否应该触发自动封禁
35
+ """
36
+ return (
37
+ await get_auto_ban_enabled()
38
+ and status_code in await get_auto_ban_error_codes()
39
+ )
40
+
41
+
42
+ async def handle_auto_ban(
43
+ credential_manager: CredentialManager,
44
+ status_code: int,
45
+ credential_name: str,
46
+ mode: str = "geminicli"
47
+ ) -> None:
48
+ """
49
+ 处理自动封禁:直接禁用凭证
50
+
51
+ Args:
52
+ credential_manager: 凭证管理器实例
53
+ status_code: HTTP状态码
54
+ credential_name: 凭证名称
55
+ mode: 模式(geminicli 或 antigravity)
56
+ """
57
+ if credential_manager and credential_name:
58
+ log.warning(
59
+ f"[{mode.upper()} AUTO_BAN] Status {status_code} triggers auto-ban for credential: {credential_name}"
60
+ )
61
+ await credential_manager.set_cred_disabled(
62
+ credential_name, True, mode=mode
63
+ )
64
+
65
+
66
+ async def handle_error_with_retry(
67
+ credential_manager: CredentialManager,
68
+ status_code: int,
69
+ credential_name: str,
70
+ retry_enabled: bool,
71
+ attempt: int,
72
+ max_retries: int,
73
+ retry_interval: float,
74
+ mode: str = "geminicli"
75
+ ) -> bool:
76
+ """
77
+ 统一处理错误和重试逻辑
78
+
79
+ 仅在以下情况下进行自动重试:
80
+ 1. 429错误(速率限制)
81
+ 2. 503错误(服务不可用)
82
+ 3. 导致凭证封禁的错误(AUTO_BAN_ERROR_CODES配置)
83
+
84
+ Args:
85
+ credential_manager: 凭证管理器实例
86
+ status_code: HTTP状态码
87
+ credential_name: 凭证名称
88
+ retry_enabled: 是否启用重试
89
+ attempt: 当前重试次数
90
+ max_retries: 最大重试次数
91
+ retry_interval: 重试间隔
92
+ mode: 模式(geminicli 或 antigravity)
93
+
94
+ Returns:
95
+ bool: True表示需要继续重试,False表示不需要重试
96
+ """
97
+ # 优先检查自动封禁
98
+ should_auto_ban = await check_should_auto_ban(status_code)
99
+
100
+ if should_auto_ban:
101
+ # 触发自动封禁
102
+ await handle_auto_ban(credential_manager, status_code, credential_name, mode)
103
+
104
+ # 自动封禁后,仍然尝试重试(会在下次循环中自动获取新凭证)
105
+ if retry_enabled and attempt < max_retries:
106
+ log.info(
107
+ f"[{mode.upper()} RETRY] Retrying with next credential after auto-ban "
108
+ f"(status {status_code}, attempt {attempt + 1}/{max_retries})"
109
+ )
110
+ await asyncio.sleep(retry_interval)
111
+ return True
112
+ return False
113
+
114
+ # 如果不触发自动封禁,仅对429和503错误进行重试
115
+ if (status_code == 429 or status_code == 503) and retry_enabled and attempt < max_retries:
116
+ log.info(
117
+ f"[{mode.upper()} RETRY] {status_code} error encountered, retrying "
118
+ f"(attempt {attempt + 1}/{max_retries})"
119
+ )
120
+ await asyncio.sleep(retry_interval)
121
+ return True
122
+
123
+ # 其他错误不进行重试
124
+ return False
125
+
126
+
127
+ # ==================== 重试配置获取 ====================
128
+
129
+ async def get_retry_config() -> Dict[str, Any]:
130
+ """
131
+ 获取重试配置
132
+
133
+ Returns:
134
+ 包含重试配置的字典
135
+ """
136
+ return {
137
+ "retry_enabled": await get_retry_429_enabled(),
138
+ "max_retries": await get_retry_429_max_retries(),
139
+ "retry_interval": await get_retry_429_interval(),
140
+ }
141
+
142
+
143
+ # ==================== API调用结果记录 ====================
144
+
145
+ async def record_api_call_success(
146
+ credential_manager: CredentialManager,
147
+ credential_name: str,
148
+ mode: str = "geminicli",
149
+ model_name: Optional[str] = None
150
+ ) -> None:
151
+ """
152
+ 记录API调用成功
153
+
154
+ Args:
155
+ credential_manager: 凭证管理器实例
156
+ credential_name: 凭证名称
157
+ mode: 模式(geminicli 或 antigravity)
158
+ model_name: 模型名称(用于模型级CD)
159
+ """
160
+ if credential_manager and credential_name:
161
+ await credential_manager.record_api_call_result(
162
+ credential_name, True, mode=mode, model_name=model_name
163
+ )
164
+
165
+
166
+ async def record_api_call_error(
167
+ credential_manager: CredentialManager,
168
+ credential_name: str,
169
+ status_code: int,
170
+ cooldown_until: Optional[float] = None,
171
+ mode: str = "geminicli",
172
+ model_name: Optional[str] = None,
173
+ error_message: Optional[str] = None
174
+ ) -> None:
175
+ """
176
+ 记录API调用错误
177
+
178
+ Args:
179
+ credential_manager: 凭证管理器实例
180
+ credential_name: 凭证名称
181
+ status_code: HTTP状态码
182
+ cooldown_until: 冷却截止时间(Unix时间戳)
183
+ mode: 模式(geminicli 或 antigravity)
184
+ model_name: 模型名称(用于模型级CD)
185
+ error_message: 错误信息(可选)
186
+ """
187
+ if credential_manager and credential_name:
188
+ await credential_manager.record_api_call_result(
189
+ credential_name,
190
+ False,
191
+ status_code,
192
+ cooldown_until=cooldown_until,
193
+ mode=mode,
194
+ model_name=model_name,
195
+ error_message=error_message
196
+ )
197
+
198
+
199
+ # ==================== 429错误处理 ====================
200
+
201
+ async def parse_and_log_cooldown(
202
+ error_text: str,
203
+ mode: str = "geminicli"
204
+ ) -> Optional[float]:
205
+ """
206
+ 解析并记录冷却时间
207
+
208
+ Args:
209
+ error_text: 错误响应文本
210
+ mode: 模式(geminicli 或 antigravity)
211
+
212
+ Returns:
213
+ 冷却截止时间(Unix时间戳),如果解析失败则返回None
214
+ """
215
+ try:
216
+ error_data = json.loads(error_text)
217
+ cooldown_until = parse_quota_reset_timestamp(error_data)
218
+ if cooldown_until:
219
+ log.info(
220
+ f"[{mode.upper()}] 检测到quota冷却时间: "
221
+ f"{datetime.fromtimestamp(cooldown_until, timezone.utc).isoformat()}"
222
+ )
223
+ return cooldown_until
224
+ except Exception as parse_err:
225
+ log.debug(f"[{mode.upper()}] Failed to parse cooldown time: {parse_err}")
226
+ return None
227
+
228
+
229
+ # ==================== 流式响应收集 ====================
230
+
231
+ async def collect_streaming_response(stream_generator) -> Response:
232
+ """
233
+ 将Gemini流式响应收集为一条完整的非流式响应
234
+
235
+ Args:
236
+ stream_generator: 流式响应生成器,产生 "data: {json}" 格式的行或Response对象
237
+
238
+ Returns:
239
+ Response: 合并后的完整响应对象
240
+
241
+ Example:
242
+ >>> async for line in stream_generator:
243
+ ... # line format: "data: {...}" or Response object
244
+ >>> response = await collect_streaming_response(stream_generator)
245
+ """
246
+ # 初始化响应结构
247
+ merged_response = {
248
+ "response": {
249
+ "candidates": [{
250
+ "content": {
251
+ "parts": [],
252
+ "role": "model"
253
+ },
254
+ "finishReason": None,
255
+ "safetyRatings": [],
256
+ "citationMetadata": None
257
+ }],
258
+ "usageMetadata": {
259
+ "promptTokenCount": 0,
260
+ "candidatesTokenCount": 0,
261
+ "totalTokenCount": 0
262
+ }
263
+ }
264
+ }
265
+
266
+ collected_text = [] # 用于收集文本内容
267
+ collected_thought_text = [] # 用于收集思维链内容
268
+ collected_other_parts = [] # 用于收集其他类型的parts(图片、文件、工具调用等)
269
+ collected_tool_parts_count = 0 # 记录工具调用相关part数量
270
+ has_data = False
271
+ line_count = 0
272
+
273
+ log.debug("[STREAM COLLECTOR] Starting to collect streaming response")
274
+
275
+ try:
276
+ async for line in stream_generator:
277
+ line_count += 1
278
+
279
+ # 如果收到的是Response对象(错误),直接返回
280
+ if isinstance(line, Response):
281
+ log.debug(f"[STREAM COLLECTOR] 收到错误Response,状态码: {line.status_code}")
282
+ return line
283
+
284
+ # 处理 bytes 类型
285
+ if isinstance(line, bytes):
286
+ line_str = line.decode('utf-8', errors='ignore')
287
+ log.debug(f"[STREAM COLLECTOR] Processing bytes line {line_count}: {line_str[:200] if line_str else 'empty'}")
288
+ elif isinstance(line, str):
289
+ line_str = line
290
+ log.debug(f"[STREAM COLLECTOR] Processing line {line_count}: {line_str[:200] if line_str else 'empty'}")
291
+ else:
292
+ log.debug(f"[STREAM COLLECTOR] Skipping non-string/bytes line: {type(line)}")
293
+ continue
294
+
295
+ # 解析流式数据行
296
+ if not line_str.startswith("data: "):
297
+ log.debug(f"[STREAM COLLECTOR] Skipping line without 'data: ' prefix: {line_str[:100]}")
298
+ continue
299
+
300
+ raw = line_str[6:].strip()
301
+ if raw == "[DONE]":
302
+ log.debug("[STREAM COLLECTOR] Received [DONE] marker")
303
+ break
304
+
305
+ try:
306
+ log.debug(f"[STREAM COLLECTOR] Parsing JSON: {raw[:200]}")
307
+ chunk = json.loads(raw)
308
+ has_data = True
309
+ log.debug(f"[STREAM COLLECTOR] Chunk keys: {chunk.keys() if isinstance(chunk, dict) else type(chunk)}")
310
+
311
+ # 提取响应对象
312
+ response_obj = chunk.get("response", {})
313
+ if not response_obj:
314
+ log.debug("[STREAM COLLECTOR] No 'response' key in chunk, trying direct access")
315
+ response_obj = chunk # 尝试直接使用chunk
316
+
317
+ candidates = response_obj.get("candidates", [])
318
+ log.debug(f"[STREAM COLLECTOR] Found {len(candidates)} candidates")
319
+ if not candidates:
320
+ log.debug(f"[STREAM COLLECTOR] No candidates in chunk, chunk structure: {list(chunk.keys()) if isinstance(chunk, dict) else type(chunk)}")
321
+ continue
322
+
323
+ candidate = candidates[0]
324
+
325
+ # 收集文本内容
326
+ content = candidate.get("content", {})
327
+ parts = content.get("parts", [])
328
+ log.debug(f"[STREAM COLLECTOR] Processing {len(parts)} parts from candidate")
329
+
330
+ for part in parts:
331
+ if not isinstance(part, dict):
332
+ continue
333
+
334
+ # 优先保留工具调用相关 part(functionCall / functionResponse)
335
+ # 避免在 stream2nostream 模式下工具调用丢失
336
+ if "functionCall" in part or "functionResponse" in part or "function_call" in part:
337
+ collected_other_parts.append(part)
338
+ collected_tool_parts_count += 1
339
+ log.debug(f"[STREAM COLLECTOR] Collected tool part: {list(part.keys())}")
340
+ continue
341
+
342
+ # 处理文本内容
343
+ text = part.get("text", "")
344
+ if text:
345
+ # 区分普通文本和思维链
346
+ if part.get("thought", False):
347
+ collected_thought_text.append(text)
348
+ log.debug(f"[STREAM COLLECTOR] Collected thought text: {text[:100]}")
349
+ else:
350
+ collected_text.append(text)
351
+ log.debug(f"[STREAM COLLECTOR] Collected regular text: {text[:100]}")
352
+ # 处理非文本内容(图片、文件等)
353
+ elif "inlineData" in part or "fileData" in part or "executableCode" in part or "codeExecutionResult" in part:
354
+ collected_other_parts.append(part)
355
+ log.debug(f"[STREAM COLLECTOR] Collected non-text part: {list(part.keys())}")
356
+
357
+ # 收集其他信息(使用最后一个块的值)
358
+ if candidate.get("finishReason"):
359
+ merged_response["response"]["candidates"][0]["finishReason"] = candidate["finishReason"]
360
+
361
+ if candidate.get("safetyRatings"):
362
+ merged_response["response"]["candidates"][0]["safetyRatings"] = candidate["safetyRatings"]
363
+
364
+ if candidate.get("citationMetadata"):
365
+ merged_response["response"]["candidates"][0]["citationMetadata"] = candidate["citationMetadata"]
366
+
367
+ # 更新使用元数据
368
+ usage = response_obj.get("usageMetadata", {})
369
+ if usage:
370
+ merged_response["response"]["usageMetadata"].update(usage)
371
+
372
+ except json.JSONDecodeError as e:
373
+ log.debug(f"[STREAM COLLECTOR] Failed to parse JSON chunk: {e}")
374
+ continue
375
+ except Exception as e:
376
+ log.debug(f"[STREAM COLLECTOR] Error processing chunk: {e}")
377
+ continue
378
+
379
+ except Exception as e:
380
+ log.error(f"[STREAM COLLECTOR] Error collecting stream after {line_count} lines: {e}")
381
+ return Response(
382
+ content=json.dumps({"error": f"收集流式响应失败: {str(e)}"}),
383
+ status_code=500,
384
+ media_type="application/json"
385
+ )
386
+
387
+ log.debug(f"[STREAM COLLECTOR] Finished iteration, has_data={has_data}, line_count={line_count}")
388
+
389
+ # 如果没有收集到任何数据,返回错误
390
+ if not has_data:
391
+ log.error(f"[STREAM COLLECTOR] No data collected from stream after {line_count} lines")
392
+ return Response(
393
+ content=json.dumps({"error": "No data collected from stream"}),
394
+ status_code=500,
395
+ media_type="application/json"
396
+ )
397
+
398
+ # 组装最终的parts
399
+ final_parts = []
400
+
401
+ # 先添加思维链内容(如果有)
402
+ if collected_thought_text:
403
+ final_parts.append({
404
+ "text": "".join(collected_thought_text),
405
+ "thought": True
406
+ })
407
+
408
+ # 再添加普通文本内容
409
+ if collected_text:
410
+ final_parts.append({
411
+ "text": "".join(collected_text)
412
+ })
413
+
414
+ # 添加其他类型的parts(图片、文件等)
415
+ final_parts.extend(collected_other_parts)
416
+
417
+ # 如果没有任何内容,添加空文本
418
+ if not final_parts:
419
+ final_parts.append({"text": ""})
420
+
421
+ merged_response["response"]["candidates"][0]["content"]["parts"] = final_parts
422
+
423
+ log.info(
424
+ f"[STREAM COLLECTOR] Collected {len(collected_text)} text chunks, "
425
+ f"{len(collected_thought_text)} thought chunks, {len(collected_other_parts)} other parts "
426
+ f"(tool parts: {collected_tool_parts_count})"
427
+ )
428
+
429
+ # 去掉嵌套的 "response" 包装(Antigravity格式 -> 标准Gemini格式)
430
+ if "response" in merged_response and "candidates" not in merged_response:
431
+ log.debug(f"[STREAM COLLECTOR] 展开response包装")
432
+ merged_response = merged_response["response"]
433
+
434
+ # 返回纯JSON格式
435
+ return Response(
436
+ content=json.dumps(merged_response, ensure_ascii=False).encode('utf-8'),
437
+ status_code=200,
438
+ headers={},
439
+ media_type="application/json"
440
+ )
441
+
442
+
443
+ RESOURCE_EXHAUSTED_COOLDOWN_HOURS = 4 # RESOURCE_EXHAUSTED 错误的默认冷却时间(小时)
444
+
445
+
446
+ def parse_quota_reset_timestamp(error_response: dict) -> Optional[float]:
447
+ """
448
+ 从Google API错误响应中提取quota重置时间戳
449
+
450
+ Args:
451
+ error_response: Google API返回的错误响应字典
452
+
453
+ Returns:
454
+ Unix时间戳(秒),如果无法解析则返回None
455
+
456
+ 示例错误响应:
457
+ {
458
+ "error": {
459
+ "code": 429,
460
+ "message": "You have exhausted your capacity...",
461
+ "status": "RESOURCE_EXHAUSTED",
462
+ "details": [
463
+ {
464
+ "@type": "type.googleapis.com/google.rpc.ErrorInfo",
465
+ "reason": "QUOTA_EXHAUSTED",
466
+ "metadata": {
467
+ "quotaResetTimeStamp": "2025-11-30T14:57:24Z",
468
+ "quotaResetDelay": "13h19m1.20964964s"
469
+ }
470
+ }
471
+ ]
472
+ }
473
+ }
474
+ """
475
+ try:
476
+ error_obj = error_response.get("error", {})
477
+ details = error_obj.get("details", [])
478
+
479
+ for detail in details:
480
+ if detail.get("@type") == "type.googleapis.com/google.rpc.ErrorInfo":
481
+ reset_timestamp_str = detail.get("metadata", {}).get("quotaResetTimeStamp")
482
+
483
+ if reset_timestamp_str:
484
+ if reset_timestamp_str.endswith("Z"):
485
+ reset_timestamp_str = reset_timestamp_str.replace("Z", "+00:00")
486
+
487
+ reset_dt = datetime.fromisoformat(reset_timestamp_str)
488
+ if reset_dt.tzinfo is None:
489
+ reset_dt = reset_dt.replace(tzinfo=timezone.utc)
490
+
491
+ return reset_dt.astimezone(timezone.utc).timestamp()
492
+
493
+ # 如果是 RESOURCE_EXHAUSTED 错误且消息完全匹配,设置默认4小时冷却时间
494
+ if (
495
+ error_obj.get("status") == "RESOURCE_EXHAUSTED"
496
+ and error_obj.get("message") == "Resource has been exhausted (e.g. check quota)."
497
+ ):
498
+ import time
499
+ cooldown_until = time.time() + RESOURCE_EXHAUSTED_COOLDOWN_HOURS * 3600
500
+ return cooldown_until
501
+
502
+ return None
503
+
504
+ except Exception:
505
+ return None
src/auth.py ADDED
@@ -0,0 +1,1089 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 认证API模块
3
+ """
4
+
5
+ import asyncio
6
+ import socket
7
+ import threading
8
+ import time
9
+ import uuid
10
+ from datetime import timezone
11
+ from http.server import BaseHTTPRequestHandler, HTTPServer
12
+ from typing import Any, Dict, Optional
13
+ from urllib.parse import parse_qs, urlparse
14
+
15
+ from config import get_config_value, get_code_assist_endpoint
16
+ from log import log
17
+
18
+ from .google_oauth_api import (
19
+ Credentials,
20
+ Flow,
21
+ enable_required_apis,
22
+ fetch_project_id_and_tier,
23
+ get_user_projects,
24
+ select_default_project,
25
+ )
26
+ from .storage_adapter import get_storage_adapter
27
+ from .utils import (
28
+ ANTIGRAVITY_CLIENT_ID,
29
+ ANTIGRAVITY_CLIENT_SECRET,
30
+ ANTIGRAVITY_SCOPES,
31
+ ANTIGRAVITY_USER_AGENT,
32
+ CALLBACK_HOST,
33
+ CLIENT_ID,
34
+ CLIENT_SECRET,
35
+ SCOPES,
36
+ GEMINICLI_USER_AGENT,
37
+ TOKEN_URL,
38
+ )
39
+
40
+
41
+ async def get_callback_port():
42
+ """获取OAuth回调端口"""
43
+ return int(await get_config_value("oauth_callback_port", "11451", "OAUTH_CALLBACK_PORT"))
44
+
45
+
46
+ def _prepare_credentials_data(credentials: Credentials, project_id: str, mode: str = "geminicli", subscription_tier: str = None) -> Dict[str, Any]:
47
+ """准备凭证数据字典(统一函数)"""
48
+ if mode == "antigravity":
49
+ creds_data = {
50
+ "client_id": ANTIGRAVITY_CLIENT_ID,
51
+ "client_secret": ANTIGRAVITY_CLIENT_SECRET,
52
+ "token": credentials.access_token,
53
+ "refresh_token": credentials.refresh_token,
54
+ "scopes": ANTIGRAVITY_SCOPES,
55
+ "token_uri": TOKEN_URL,
56
+ "project_id": project_id,
57
+ }
58
+ else:
59
+ creds_data = {
60
+ "client_id": CLIENT_ID,
61
+ "client_secret": CLIENT_SECRET,
62
+ "token": credentials.access_token,
63
+ "refresh_token": credentials.refresh_token,
64
+ "scopes": SCOPES,
65
+ "token_uri": TOKEN_URL,
66
+ "project_id": project_id,
67
+ }
68
+
69
+ if credentials.expires_at:
70
+ if credentials.expires_at.tzinfo is None:
71
+ expiry_utc = credentials.expires_at.replace(tzinfo=timezone.utc)
72
+ else:
73
+ expiry_utc = credentials.expires_at
74
+ creds_data["expiry"] = expiry_utc.isoformat()
75
+
76
+ return creds_data
77
+
78
+
79
+ def _cleanup_auth_flow_server(state: str):
80
+ """清理认证流程的服务器资源"""
81
+ if state in auth_flows:
82
+ flow_data_to_clean = auth_flows[state]
83
+ try:
84
+ if flow_data_to_clean.get("server"):
85
+ server = flow_data_to_clean["server"]
86
+ port = flow_data_to_clean.get("callback_port")
87
+ async_shutdown_server(server, port)
88
+ except Exception as e:
89
+ log.debug(f"关闭服务器时出错: {e}")
90
+ del auth_flows[state]
91
+
92
+
93
+ class _OAuthLibPatcher:
94
+ """oauthlib参数验证补丁的上下文管理器"""
95
+ def __init__(self):
96
+ import oauthlib.oauth2.rfc6749.parameters
97
+ self.module = oauthlib.oauth2.rfc6749.parameters
98
+ self.original_validate = None
99
+
100
+ def __enter__(self):
101
+ self.original_validate = self.module.validate_token_parameters
102
+
103
+ def patched_validate(params):
104
+ try:
105
+ return self.original_validate(params)
106
+ except Warning:
107
+ pass
108
+
109
+ self.module.validate_token_parameters = patched_validate
110
+ return self
111
+
112
+ def __exit__(self, exc_type, exc_val, exc_tb):
113
+ if self.original_validate:
114
+ self.module.validate_token_parameters = self.original_validate
115
+
116
+
117
+ # 全局状态管理 - 严格限制大小
118
+ auth_flows = {} # 存储进行中的认证流程
119
+ MAX_AUTH_FLOWS = 20 # 严格限制最大认证流程数
120
+ DEFAULT_PROJECT_ID = "gemini-pro-1751713012-07fc4dfd"
121
+
122
+
123
+ def cleanup_auth_flows_for_memory():
124
+ """清理认证流程以释放内存"""
125
+ global auth_flows
126
+ cleanup_expired_flows()
127
+ # 如果还是太多,强制清理一些旧的流程
128
+ if len(auth_flows) > 10:
129
+ # 按创建时间排序,保留最新的10个
130
+ sorted_flows = sorted(
131
+ auth_flows.items(), key=lambda x: x[1].get("created_at", 0), reverse=True
132
+ )
133
+ new_auth_flows = dict(sorted_flows[:10])
134
+
135
+ # 清理被移除的流程
136
+ for state, flow_data in auth_flows.items():
137
+ if state not in new_auth_flows:
138
+ try:
139
+ if flow_data.get("server"):
140
+ server = flow_data["server"]
141
+ port = flow_data.get("callback_port")
142
+ async_shutdown_server(server, port)
143
+ except Exception:
144
+ pass
145
+ flow_data.clear()
146
+
147
+ auth_flows = new_auth_flows
148
+ log.info(f"强制清理认证流程,保留 {len(auth_flows)} 个最新流程")
149
+
150
+ return len(auth_flows)
151
+
152
+
153
+ async def find_available_port(start_port: int = None) -> int:
154
+ """动态查找可用端口"""
155
+ if start_port is None:
156
+ start_port = await get_callback_port()
157
+
158
+ # 首先尝试默认端口
159
+ for port in range(start_port, start_port + 100): # 尝试100个端口
160
+ try:
161
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
162
+ s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
163
+ s.bind(("0.0.0.0", port))
164
+ log.info(f"找到可用端口: {port}")
165
+ return port
166
+ except OSError:
167
+ continue
168
+
169
+ # 如果都不可用,让系统自动分配端口
170
+ try:
171
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
172
+ s.bind(("0.0.0.0", 0))
173
+ port = s.getsockname()[1]
174
+ log.info(f"系统分配可用端口: {port}")
175
+ return port
176
+ except OSError as e:
177
+ log.error(f"无法找到可用端口: {e}")
178
+ raise RuntimeError("无法找到可用端口")
179
+
180
+
181
+ def create_callback_server(port: int) -> HTTPServer:
182
+ """创建指定端口的回调服务器,优化快速关闭"""
183
+ try:
184
+ # 服务器监听0.0.0.0
185
+ server = HTTPServer(("0.0.0.0", port), AuthCallbackHandler)
186
+
187
+ # 设置socket选项以支持快速关闭
188
+ server.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
189
+ # 设置较短的超时时间
190
+ server.timeout = 1.0
191
+
192
+ log.info(f"创建OAuth回调服务器,监听端口: {port}")
193
+ return server
194
+ except OSError as e:
195
+ log.error(f"创建端口{port}的服务器失败: {e}")
196
+ raise
197
+
198
+
199
+ class AuthCallbackHandler(BaseHTTPRequestHandler):
200
+ """OAuth回调处理器"""
201
+
202
+ def do_GET(self):
203
+ query_components = parse_qs(urlparse(self.path).query)
204
+ code = query_components.get("code", [None])[0]
205
+ state = query_components.get("state", [None])[0]
206
+
207
+ log.info(f"收到OAuth回调: code={'已获取' if code else '未获取'}, state={state}")
208
+
209
+ if code and state and state in auth_flows:
210
+ # 更新流程状态
211
+ auth_flows[state]["code"] = code
212
+ auth_flows[state]["completed"] = True
213
+
214
+ log.info(f"OAuth回调成功处理: state={state}")
215
+
216
+ self.send_response(200)
217
+ self.send_header("Content-type", "text/html")
218
+ self.end_headers()
219
+ # 成功页面
220
+ self.wfile.write(
221
+ b"<h1>OAuth authentication successful!</h1><p>You can close this window. Please return to the original page and click 'Get Credentials' button.</p>"
222
+ )
223
+ else:
224
+ self.send_response(400)
225
+ self.send_header("Content-type", "text/html")
226
+ self.end_headers()
227
+ self.wfile.write(b"<h1>Authentication failed.</h1><p>Please try again.</p>")
228
+
229
+ def log_message(self, format, *args):
230
+ # 减少日志噪音
231
+ pass
232
+
233
+
234
+ async def create_auth_url(
235
+ project_id: Optional[str] = None, user_session: str = None, mode: str = "geminicli"
236
+ ) -> Dict[str, Any]:
237
+ """创建认证URL,支持动态端口分配"""
238
+ try:
239
+ # 动态分配端口
240
+ callback_port = await find_available_port()
241
+ callback_url = f"http://{CALLBACK_HOST}:{callback_port}"
242
+
243
+ # 立即启动回调服务器
244
+ try:
245
+ callback_server = create_callback_server(callback_port)
246
+ # 在后台线程中运行服务器
247
+ server_thread = threading.Thread(
248
+ target=callback_server.serve_forever,
249
+ daemon=True,
250
+ name=f"OAuth-Server-{callback_port}",
251
+ )
252
+ server_thread.start()
253
+ log.info(f"OAuth回调服务器已启动,端口: {callback_port}")
254
+ except Exception as e:
255
+ log.error(f"启动回调服务器失败: {e}")
256
+ return {
257
+ "success": False,
258
+ "error": f"无法启动OAuth回调服务器,端口{callback_port}: {str(e)}",
259
+ }
260
+
261
+ # 创建OAuth流程
262
+ # 根据模式选择配置
263
+ if mode == "antigravity":
264
+ client_id = ANTIGRAVITY_CLIENT_ID
265
+ client_secret = ANTIGRAVITY_CLIENT_SECRET
266
+ scopes = ANTIGRAVITY_SCOPES
267
+ else:
268
+ client_id = CLIENT_ID
269
+ client_secret = CLIENT_SECRET
270
+ scopes = SCOPES
271
+
272
+ flow = Flow(
273
+ client_id=client_id,
274
+ client_secret=client_secret,
275
+ scopes=scopes,
276
+ redirect_uri=callback_url,
277
+ )
278
+
279
+ # 生成状态标识符,包含用户会话信息
280
+ if user_session:
281
+ state = f"{user_session}_{str(uuid.uuid4())}"
282
+ else:
283
+ state = str(uuid.uuid4())
284
+
285
+ # 生成认证URL
286
+ auth_url = flow.get_auth_url(state=state)
287
+
288
+ # 严格控制认证流程数量 - 超过限制时立即清理最旧的
289
+ if len(auth_flows) >= MAX_AUTH_FLOWS:
290
+ # 清理最旧的认证流程
291
+ oldest_state = min(auth_flows.keys(), key=lambda k: auth_flows[k].get("created_at", 0))
292
+ try:
293
+ # 清理服务器资源
294
+ old_flow = auth_flows[oldest_state]
295
+ if old_flow.get("server"):
296
+ server = old_flow["server"]
297
+ port = old_flow.get("callback_port")
298
+ async_shutdown_server(server, port)
299
+ except Exception as e:
300
+ log.warning(f"Failed to cleanup old auth flow {oldest_state}: {e}")
301
+
302
+ del auth_flows[oldest_state]
303
+ log.debug(f"Removed oldest auth flow: {oldest_state}")
304
+
305
+ # 保存流程状态
306
+ auth_flows[state] = {
307
+ "flow": flow,
308
+ "project_id": project_id, # 可能为None,稍后在回调时确定
309
+ "user_session": user_session,
310
+ "callback_port": callback_port, # 存储分配的端口
311
+ "callback_url": callback_url, # 存储完整回调URL
312
+ "server": callback_server, # 存储服务器实例
313
+ "server_thread": server_thread, # 存储服务器线程
314
+ "code": None,
315
+ "completed": False,
316
+ "created_at": time.time(),
317
+ "auto_project_detection": project_id is None, # 标记是否需要自动检测项目ID
318
+ "mode": mode, # 凭证模式
319
+ }
320
+
321
+ # 清理过期的流程(30分钟)
322
+ cleanup_expired_flows()
323
+
324
+ log.info(f"OAuth流程已创建: state={state}, project_id={project_id}")
325
+ log.info(f"用户需要访问认证URL,然后OAuth会回调到 {callback_url}")
326
+ log.info(f"为此认证流程分配的端口: {callback_port}")
327
+
328
+ return {
329
+ "auth_url": auth_url,
330
+ "state": state,
331
+ "callback_port": callback_port,
332
+ "success": True,
333
+ "auto_project_detection": project_id is None,
334
+ "detected_project_id": project_id,
335
+ }
336
+
337
+ except Exception as e:
338
+ log.error(f"创建认证URL失败: {e}")
339
+ return {"success": False, "error": str(e)}
340
+
341
+
342
+ def wait_for_callback_sync(state: str, timeout: int = 300) -> Optional[str]:
343
+ """同步等待OAuth回调完成,使用对应流程的专用服务器"""
344
+ if state not in auth_flows:
345
+ log.error(f"未找到状态为 {state} 的认证流程")
346
+ return None
347
+
348
+ flow_data = auth_flows[state]
349
+ callback_port = flow_data["callback_port"]
350
+
351
+ # 服务器已经在create_auth_url时启动了,这里只需要等待
352
+ log.info(f"等待OAuth回调完成,端口: {callback_port}")
353
+
354
+ # 等待回调完成
355
+ start_time = time.time()
356
+ while time.time() - start_time < timeout:
357
+ if flow_data.get("code"):
358
+ log.info("OAuth回调成功完成")
359
+ return flow_data["code"]
360
+ time.sleep(0.5) # 每0.5秒检查一次
361
+
362
+ # 刷新flow_data引用
363
+ if state in auth_flows:
364
+ flow_data = auth_flows[state]
365
+
366
+ log.warning(f"等待OAuth回调超时 ({timeout}秒)")
367
+ return None
368
+
369
+
370
+ async def complete_auth_flow(
371
+ project_id: Optional[str] = None, user_session: str = None
372
+ ) -> Dict[str, Any]:
373
+ """完成认证流程并保存凭证,支持自动检测项目ID"""
374
+ try:
375
+ # 查找对应的认证流程
376
+ state = None
377
+ flow_data = None
378
+
379
+ # 如果指定了project_id,先尝试匹配指定的项目
380
+ if project_id:
381
+ for s, data in auth_flows.items():
382
+ if data["project_id"] == project_id:
383
+ # 如果指定了用户会话,优先匹配相同会话的流程
384
+ if user_session and data.get("user_session") == user_session:
385
+ state = s
386
+ flow_data = data
387
+ break
388
+ # 如果没有指定会话,或没找到匹配会话的流程,使用第一个匹配项目ID的
389
+ elif not state:
390
+ state = s
391
+ flow_data = data
392
+
393
+ # 如果没有指定项目ID或没找到匹配的,查找需要自动检测项目ID的流程
394
+ if not state:
395
+ for s, data in auth_flows.items():
396
+ if data.get("auto_project_detection", False):
397
+ # 如果指定了用户会话,优先匹配相同会话的流程
398
+ if user_session and data.get("user_session") == user_session:
399
+ state = s
400
+ flow_data = data
401
+ break
402
+ # 使用第一个找到的需要自动检测的流程
403
+ elif not state:
404
+ state = s
405
+ flow_data = data
406
+
407
+ if not state or not flow_data:
408
+ return {"success": False, "error": "未找到对应的认证流程,请先点击获取认证链接"}
409
+
410
+ if not project_id:
411
+ project_id = flow_data.get("project_id")
412
+ if not project_id:
413
+ project_id = DEFAULT_PROJECT_ID
414
+ log.warning(f"未获取到project_id,使用默认project_id: {project_id}")
415
+
416
+ flow = flow_data["flow"]
417
+
418
+ # 如果还没有���权码,需要等待回调
419
+ if not flow_data.get("code"):
420
+ log.info(f"等待用户完成OAuth授权 (state: {state})")
421
+ auth_code = wait_for_callback_sync(state)
422
+
423
+ if not auth_code:
424
+ return {
425
+ "success": False,
426
+ "error": "未接收到授权回调,请确保完成了浏览器中的OAuth认证",
427
+ }
428
+
429
+ # 更新流程数据
430
+ auth_flows[state]["code"] = auth_code
431
+ auth_flows[state]["completed"] = True
432
+ else:
433
+ auth_code = flow_data["code"]
434
+
435
+ # 使用认证代码获取凭证
436
+ with _OAuthLibPatcher():
437
+ try:
438
+ credentials = await flow.exchange_code(auth_code)
439
+ # credentials 已经在 exchange_code 中获得
440
+
441
+ # 如果需要自动检测项目ID且没有提供项目ID
442
+ if flow_data.get("auto_project_detection", False) and not project_id:
443
+ log.info("尝试通过API获取用户项目列表...")
444
+ log.info(f"使用的token: {credentials.access_token[:20]}...")
445
+ log.info(f"Token过期时间: {credentials.expires_at}")
446
+ user_projects = await get_user_projects(credentials)
447
+
448
+ if user_projects:
449
+ # 如果只有一个项目,自动使用
450
+ if len(user_projects) == 1:
451
+ # Google API returns projectId in camelCase
452
+ project_id = user_projects[0].get("projectId")
453
+ if project_id:
454
+ flow_data["project_id"] = project_id
455
+ log.info(f"自动选择唯一项目: {project_id}")
456
+ # 如果有多个项目,尝试选择默认项目
457
+ else:
458
+ project_id = await select_default_project(user_projects)
459
+ if project_id:
460
+ flow_data["project_id"] = project_id
461
+ log.info(f"自动选择默认项目: {project_id}")
462
+ else:
463
+ # 返回项目列表让用户选择
464
+ return {
465
+ "success": False,
466
+ "error": "请从以下项目中选择一个",
467
+ "requires_project_selection": True,
468
+ "available_projects": [
469
+ {
470
+ # Google API returns projectId in camelCase
471
+ "project_id": p.get("projectId"),
472
+ "name": p.get("displayName") or p.get("projectId"),
473
+ "projectNumber": p.get("projectNumber"),
474
+ }
475
+ for p in user_projects
476
+ ],
477
+ }
478
+ else:
479
+ # 如果无法获取项目列表,使用默认project_id
480
+ project_id = DEFAULT_PROJECT_ID
481
+ flow_data["project_id"] = project_id
482
+ log.warning(f"无法获取项目列表,使用默认project_id: {project_id}")
483
+
484
+ # 如果仍然没有项目ID,返回错误
485
+ if not project_id:
486
+ project_id = DEFAULT_PROJECT_ID
487
+ flow_data["project_id"] = project_id
488
+ log.warning(f"仍未获取到project_id,使用默认project_id: {project_id}")
489
+
490
+ # 保存凭证
491
+ saved_filename = await save_credentials(credentials, project_id)
492
+
493
+ # 准备返回的凭证数据
494
+ creds_data = _prepare_credentials_data(credentials, project_id, mode="geminicli")
495
+
496
+ # 清理使用过的流程
497
+ _cleanup_auth_flow_server(state)
498
+
499
+ log.info("OAuth认证成功,凭证已保存")
500
+ return {
501
+ "success": True,
502
+ "credentials": creds_data,
503
+ "file_path": saved_filename,
504
+ "auto_detected_project": flow_data.get("auto_project_detection", False),
505
+ }
506
+
507
+ except Exception as e:
508
+ log.error(f"获取凭证失败: {e}")
509
+ return {"success": False, "error": f"获取凭证失败: {str(e)}"}
510
+
511
+ except Exception as e:
512
+ log.error(f"完成认证流程失败: {e}")
513
+ return {"success": False, "error": str(e)}
514
+
515
+
516
+ async def asyncio_complete_auth_flow(
517
+ project_id: Optional[str] = None, user_session: str = None, mode: str = "geminicli"
518
+ ) -> Dict[str, Any]:
519
+ """异步完成认证流程,支持自动检测项目ID"""
520
+ try:
521
+ log.info(
522
+ f"asyncio_complete_auth_flow开始执行: project_id={project_id}, user_session={user_session}"
523
+ )
524
+
525
+ # 查找对应的认证流程
526
+ state = None
527
+ flow_data = None
528
+
529
+ log.debug(f"当前所有auth_flows: {list(auth_flows.keys())}")
530
+
531
+ # 如果指定了project_id,先尝试匹配指定的项目
532
+ if project_id:
533
+ log.info(f"尝试匹配指定的项目ID: {project_id}")
534
+ for s, data in auth_flows.items():
535
+ if data["project_id"] == project_id:
536
+ # 如果指定了用户会话,优先匹配相同会话的流程
537
+ if user_session and data.get("user_session") == user_session:
538
+ state = s
539
+ flow_data = data
540
+ log.info(f"找到匹配的用户会话: {s}")
541
+ break
542
+ # 如果没有指定会话,或没找到匹配会话的流程,使用第一个匹配项目ID的
543
+ elif not state:
544
+ state = s
545
+ flow_data = data
546
+ log.info(f"找到匹配的项目ID: {s}")
547
+
548
+ # 如果没有指定项目ID或没找到匹配的,查找需要自动检测项目ID的流程
549
+ if not state:
550
+ log.info("没有找到指定项目的流程,查找自动检测流程")
551
+ # 首先尝试找到已完成的流程(有授权码的)
552
+ completed_flows = []
553
+ for s, data in auth_flows.items():
554
+ if data.get("auto_project_detection", False):
555
+ if user_session and data.get("user_session") == user_session:
556
+ if data.get("code"): # 优先选择已完成的
557
+ completed_flows.append((s, data, data.get("created_at", 0)))
558
+
559
+ # 如果有已完成的流程,选择最新的
560
+ if completed_flows:
561
+ completed_flows.sort(key=lambda x: x[2], reverse=True) # 按时间倒序
562
+ state, flow_data, _ = completed_flows[0]
563
+ log.info(f"找到已完成的最新认证流程: {state}")
564
+ else:
565
+ # 如果没有已完成的,找最新的未完成流程
566
+ pending_flows = []
567
+ for s, data in auth_flows.items():
568
+ if data.get("auto_project_detection", False):
569
+ if user_session and data.get("user_session") == user_session:
570
+ pending_flows.append((s, data, data.get("created_at", 0)))
571
+ elif not user_session:
572
+ pending_flows.append((s, data, data.get("created_at", 0)))
573
+
574
+ if pending_flows:
575
+ pending_flows.sort(key=lambda x: x[2], reverse=True) # 按时间倒序
576
+ state, flow_data, _ = pending_flows[0]
577
+ log.info(f"找到最新的待完成认证流程: {state}")
578
+
579
+ if not state or not flow_data:
580
+ log.error(f"未找到认证流程: state={state}, flow_data存在={bool(flow_data)}")
581
+ log.debug(f"当前所有flow_data: {list(auth_flows.keys())}")
582
+ return {"success": False, "error": "未找到对应的认证流程,请先点击获取认证链接"}
583
+
584
+ log.info(f"找到认证流程: state={state}")
585
+ log.info(
586
+ f"flow_data内容: project_id={flow_data.get('project_id')}, auto_project_detection={flow_data.get('auto_project_detection')}"
587
+ )
588
+ log.info(f"传入的project_id参数: {project_id}")
589
+
590
+ # 如果需要自动检测项目ID且没有提供项目ID
591
+ log.info(
592
+ f"检查auto_project_detection条件: auto_project_detection={flow_data.get('auto_project_detection', False)}, not project_id={not project_id}"
593
+ )
594
+ if flow_data.get("auto_project_detection", False) and not project_id:
595
+ log.info("跳过自动检测项目ID,进入等待阶段")
596
+ elif not project_id:
597
+ log.info("进入project_id检查分支")
598
+ project_id = flow_data.get("project_id")
599
+ if not project_id:
600
+ project_id = DEFAULT_PROJECT_ID
601
+ flow_data["project_id"] = project_id
602
+ log.warning(f"缺少项目ID,使用默认project_id: {project_id}")
603
+ else:
604
+ log.info(f"使用提供的项目ID: {project_id}")
605
+
606
+ # 检查是否已经有授权码
607
+ log.info("开始检查OAuth授权码...")
608
+ log.info(f"等待state={state}的授权回调,回调端口: {flow_data.get('callback_port')}")
609
+ log.info(f"当前flow_data状态: completed={flow_data.get('completed')}, code存在={bool(flow_data.get('code'))}")
610
+ max_wait_time = 60 # 最多等待60秒
611
+ wait_interval = 1 # 每秒检查一次
612
+ waited = 0
613
+
614
+ while waited < max_wait_time:
615
+ if flow_data.get("code"):
616
+ log.info(f"检测到OAuth授权码,开始处理凭证 (等待时间: {waited}秒)")
617
+ break
618
+
619
+ # 每5秒输出一次提示
620
+ if waited % 5 == 0 and waited > 0:
621
+ log.info(f"仍在等待OAuth授权... ({waited}/{max_wait_time}秒)")
622
+ log.debug(f"当前state: {state}, flow_data keys: {list(flow_data.keys())}")
623
+
624
+ # 异步等待
625
+ await asyncio.sleep(wait_interval)
626
+ waited += wait_interval
627
+
628
+ # 刷新flow_data引用,因为可能被回调更新了
629
+ if state in auth_flows:
630
+ flow_data = auth_flows[state]
631
+
632
+ if not flow_data.get("code"):
633
+ log.error(f"等待OAuth回调超时,等待了{waited}秒")
634
+ return {
635
+ "success": False,
636
+ "error": "等待OAuth回调超时,请确保完成了浏览器中的认证并看到成功页面",
637
+ }
638
+
639
+ flow = flow_data["flow"]
640
+ auth_code = flow_data["code"]
641
+
642
+ log.info(f"开始使用授权码获取凭证: code={'***' + auth_code[-4:] if auth_code else 'None'}")
643
+
644
+ # 使用认证代码获取凭证
645
+ with _OAuthLibPatcher():
646
+ try:
647
+ log.info("调用flow.exchange_code...")
648
+ credentials = await flow.exchange_code(auth_code)
649
+ log.info(
650
+ f"成功获取凭证,token前缀: {credentials.access_token[:20] if credentials.access_token else 'None'}..."
651
+ )
652
+
653
+ log.info(
654
+ f"检查是否需要项目检测: auto_project_detection={flow_data.get('auto_project_detection')}, project_id={project_id}"
655
+ )
656
+
657
+ # 检查凭证模式
658
+ cred_mode = flow_data.get("mode", "geminicli") if flow_data.get("mode") else mode
659
+ if cred_mode == "antigravity":
660
+ log.info("Antigravity模式:从API获取project_id...")
661
+ # 使用API获取project_id
662
+ antigravity_url = await get_code_assist_endpoint()
663
+ project_id, subscription_tier = await fetch_project_id_and_tier(
664
+ credentials.access_token,
665
+ ANTIGRAVITY_USER_AGENT,
666
+ antigravity_url
667
+ )
668
+ if project_id:
669
+ log.info(f"成功从API获取project_id: {project_id}, tier: {subscription_tier}")
670
+ else:
671
+ project_id = DEFAULT_PROJECT_ID
672
+ log.warning(f"无法从API获取project_id,使用默认project_id: {project_id}")
673
+
674
+ # 保存antigravity凭证
675
+ saved_filename = await save_credentials(credentials, project_id, mode="antigravity", subscription_tier=subscription_tier)
676
+
677
+ # 准备返回的凭证数据
678
+ creds_data = _prepare_credentials_data(credentials, project_id, mode="antigravity", subscription_tier=subscription_tier)
679
+
680
+ # 清理使用过的流程
681
+ _cleanup_auth_flow_server(state)
682
+
683
+ log.info("Antigravity OAuth认证成功,凭证已保存")
684
+ return {
685
+ "success": True,
686
+ "credentials": creds_data,
687
+ "file_path": saved_filename,
688
+ "auto_detected_project": False,
689
+ "mode": "antigravity",
690
+ }
691
+
692
+ # 如果需要自动检测项目ID且没有提供项目ID(标准模式)
693
+ if flow_data.get("auto_project_detection", False) and not project_id:
694
+ log.info("标准模式:从API获取project_id...")
695
+ # 使用API获取project_id(使用标准模式的User-Agent)
696
+ code_assist_url = await get_code_assist_endpoint()
697
+ project_id, subscription_tier = await fetch_project_id_and_tier(
698
+ credentials.access_token,
699
+ GEMINICLI_USER_AGENT,
700
+ code_assist_url
701
+ )
702
+ if project_id:
703
+ flow_data["project_id"] = project_id
704
+ log.info(f"成功从API获取project_id: {project_id}")
705
+ # 自动启用必需的API服务
706
+ log.info("正在自动启用必需的API服务...")
707
+ await enable_required_apis(credentials, project_id)
708
+ else:
709
+ log.warning("无法从API获取project_id,回退到项目列表获取方式")
710
+ # 回退到原来的项目列表获取方式
711
+ user_projects = await get_user_projects(credentials)
712
+
713
+ if user_projects:
714
+ # 如果只有一个项目,自动使用
715
+ if len(user_projects) == 1:
716
+ # Google API returns projectId in camelCase
717
+ project_id = user_projects[0].get("projectId")
718
+ if project_id:
719
+ flow_data["project_id"] = project_id
720
+ log.info(f"自动选择唯一项目: {project_id}")
721
+ # 自动启用必需的API服务
722
+ log.info("正在自动启用必需的API服务...")
723
+ await enable_required_apis(credentials, project_id)
724
+ # 如果有多个项目,尝试选择默认项目
725
+ else:
726
+ project_id = await select_default_project(user_projects)
727
+ if project_id:
728
+ flow_data["project_id"] = project_id
729
+ log.info(f"自动选择默认项目: {project_id}")
730
+ # 自动启用必需的API服务
731
+ log.info("正在自动启用必需的API服务...")
732
+ await enable_required_apis(credentials, project_id)
733
+ else:
734
+ # 返回项目列表让用户选择
735
+ return {
736
+ "success": False,
737
+ "error": "请从以下项目中选择一个",
738
+ "requires_project_selection": True,
739
+ "available_projects": [
740
+ {
741
+ # Google API returns projectId in camelCase
742
+ "project_id": p.get("projectId"),
743
+ "name": p.get("displayName") or p.get("projectId"),
744
+ "projectNumber": p.get("projectNumber"),
745
+ }
746
+ for p in user_projects
747
+ ],
748
+ }
749
+ else:
750
+ # 如果无法获取项目列表,使用默认project_id
751
+ project_id = DEFAULT_PROJECT_ID
752
+ flow_data["project_id"] = project_id
753
+ log.warning(f"无法获取项目列表,使用默认project_id: {project_id}")
754
+ elif project_id:
755
+ # 如果已经有项目ID(手动提供或环境检测),也尝试启用API服务
756
+ log.info("正在为已提供的项目ID自动启用必需的API服务...")
757
+ await enable_required_apis(credentials, project_id)
758
+
759
+ # 如果仍然没有项目ID,返回错误
760
+ if not project_id:
761
+ project_id = DEFAULT_PROJECT_ID
762
+ flow_data["project_id"] = project_id
763
+ log.warning(f"仍未获取到project_id,使用默认project_id: {project_id}")
764
+
765
+ # 保存凭证
766
+ saved_filename = await save_credentials(credentials, project_id)
767
+
768
+ # 准备返回的凭证数据
769
+ creds_data = _prepare_credentials_data(credentials, project_id, mode="geminicli")
770
+
771
+ # 清理使用过的流程
772
+ _cleanup_auth_flow_server(state)
773
+
774
+ log.info("OAuth认证成功,凭证已保存")
775
+ return {
776
+ "success": True,
777
+ "credentials": creds_data,
778
+ "file_path": saved_filename,
779
+ "auto_detected_project": flow_data.get("auto_project_detection", False),
780
+ }
781
+
782
+ except Exception as e:
783
+ log.error(f"获取凭证失败: {e}")
784
+ return {"success": False, "error": f"获取凭证失败: {str(e)}"}
785
+
786
+ except Exception as e:
787
+ log.error(f"异步完成认证流程失败: {e}")
788
+ return {"success": False, "error": str(e)}
789
+
790
+
791
+ async def complete_auth_flow_from_callback_url(
792
+ callback_url: str, project_id: Optional[str] = None, mode: str = "geminicli"
793
+ ) -> Dict[str, Any]:
794
+ """从回调URL直接完成认证流程,无需启动本地服务器"""
795
+ try:
796
+ log.info(f"开始从回调URL完成认证: {callback_url}")
797
+
798
+ # 解析回调URL
799
+ parsed_url = urlparse(callback_url)
800
+ query_params = parse_qs(parsed_url.query)
801
+
802
+ # 验证必要参数
803
+ if "state" not in query_params or "code" not in query_params:
804
+ return {"success": False, "error": "回调URL缺少必要参数 (state 或 code)"}
805
+
806
+ state = query_params["state"][0]
807
+ code = query_params["code"][0]
808
+
809
+ log.info(f"从URL解析到: state={state}, code=xxx...")
810
+
811
+ # 检查是否有对应的认证流程
812
+ if state not in auth_flows:
813
+ return {
814
+ "success": False,
815
+ "error": f"未找到对应的认证流程,请先启动认证 (state: {state})",
816
+ }
817
+
818
+ flow_data = auth_flows[state]
819
+ flow = flow_data["flow"]
820
+
821
+ # 构造回调URL(使用flow中存储的redirect_uri)
822
+ redirect_uri = flow.redirect_uri
823
+ log.info(f"使用redirect_uri: {redirect_uri}")
824
+
825
+ try:
826
+ # 使用authorization code获取token
827
+ credentials = await flow.exchange_code(code)
828
+ log.info("成功获取访问令牌")
829
+
830
+ # 检查凭证模式
831
+ cred_mode = flow_data.get("mode", "geminicli") if flow_data.get("mode") else mode
832
+ if cred_mode == "antigravity":
833
+ log.info("Antigravity模式(从回调URL):从API获取project_id...")
834
+ # 使用API获取project_id
835
+ antigravity_url = await get_code_assist_endpoint()
836
+ project_id, subscription_tier = await fetch_project_id_and_tier(
837
+ credentials.access_token,
838
+ ANTIGRAVITY_USER_AGENT,
839
+ antigravity_url
840
+ )
841
+ if project_id:
842
+ log.info(f"成功从API获取project_id: {project_id}, tier: {subscription_tier}")
843
+ else:
844
+ project_id = DEFAULT_PROJECT_ID
845
+ log.warning(f"无法从API获取project_id,使用默认project_id: {project_id}")
846
+
847
+ # 保存antigravity凭证
848
+ saved_filename = await save_credentials(credentials, project_id, mode="antigravity", subscription_tier=subscription_tier)
849
+
850
+ # 准备返回的凭证数据
851
+ creds_data = _prepare_credentials_data(credentials, project_id, mode="antigravity", subscription_tier=subscription_tier)
852
+
853
+ # 清理使用过的流程
854
+ _cleanup_auth_flow_server(state)
855
+
856
+ log.info("从回调URL完成Antigravity OAuth认证成功,凭证已保存")
857
+ return {
858
+ "success": True,
859
+ "credentials": creds_data,
860
+ "file_path": saved_filename,
861
+ "auto_detected_project": False,
862
+ "mode": "antigravity",
863
+ }
864
+
865
+ # 标准模式的项目ID处理逻辑
866
+ detected_project_id = None
867
+ auto_detected = False
868
+ subscription_tier = None
869
+
870
+ if not project_id:
871
+ # 尝试使用fetch_project_id_and_tier自动获取项目ID
872
+ try:
873
+ log.info("标准模式:从API获取project_id...")
874
+ code_assist_url = await get_code_assist_endpoint()
875
+ detected_project_id, subscription_tier = await fetch_project_id_and_tier(
876
+ credentials.access_token,
877
+ GEMINICLI_USER_AGENT,
878
+ code_assist_url
879
+ )
880
+ if detected_project_id:
881
+ auto_detected = True
882
+ log.info(f"成功从API获取project_id: {detected_project_id}, tier: {subscription_tier}")
883
+ else:
884
+ log.warning("无法从API获取project_id,回退到项目列表获取方式")
885
+ # 回退到原来的项目列表获取方式
886
+ projects = await get_user_projects(credentials)
887
+ if projects:
888
+ if len(projects) == 1:
889
+ # 只有一个项目,自动使用
890
+ # Google API returns projectId in camelCase
891
+ detected_project_id = projects[0]["projectId"]
892
+ auto_detected = True
893
+ log.info(f"自动检测到唯一项目ID: {detected_project_id}")
894
+ else:
895
+ # 多个项目,自动选择第一个
896
+ # Google API returns projectId in camelCase
897
+ detected_project_id = projects[0]["projectId"]
898
+ auto_detected = True
899
+ log.info(
900
+ f"检测到{len(projects)}个项目,自动选择第一个: {detected_project_id}"
901
+ )
902
+ log.debug(f"其他可用项目: {[p['projectId'] for p in projects[1:]]}")
903
+ else:
904
+ # 没有项目访问权限,使用默认project_id
905
+ detected_project_id = DEFAULT_PROJECT_ID
906
+ auto_detected = False
907
+ log.warning(f"未检测到可访问项目,使用默认project_id: {detected_project_id}")
908
+ except Exception as e:
909
+ log.warning(f"自动检测项目ID失败: {e},使用默认project_id")
910
+ detected_project_id = DEFAULT_PROJECT_ID
911
+ auto_detected = False
912
+ else:
913
+ detected_project_id = project_id
914
+
915
+ # 启用必需的API服务
916
+ if detected_project_id:
917
+ try:
918
+ log.info(f"正在为项目 {detected_project_id} 启用必需的API服务...")
919
+ await enable_required_apis(credentials, detected_project_id)
920
+ except Exception as e:
921
+ log.warning(f"启用API服务失败: {e}")
922
+
923
+ # 保存凭证
924
+ saved_filename = await save_credentials(credentials, detected_project_id, subscription_tier=subscription_tier)
925
+
926
+ # 准备返回的凭证数据
927
+ creds_data = _prepare_credentials_data(credentials, detected_project_id, mode="geminicli", subscription_tier=subscription_tier)
928
+
929
+ # 清理使用过的流程
930
+ _cleanup_auth_flow_server(state)
931
+
932
+ log.info("从回调URL完成OAuth认证成功,凭证已保存")
933
+ return {
934
+ "success": True,
935
+ "credentials": creds_data,
936
+ "file_path": saved_filename,
937
+ "auto_detected_project": auto_detected,
938
+ }
939
+
940
+ except Exception as e:
941
+ log.error(f"从回调URL获取凭证失败: {e}")
942
+ return {"success": False, "error": f"获取凭证失败: {str(e)}"}
943
+
944
+ except Exception as e:
945
+ log.error(f"从回调URL完成认证流程失败: {e}")
946
+ return {"success": False, "error": str(e)}
947
+
948
+
949
+ async def save_credentials(creds: Credentials, project_id: str, mode: str = "geminicli", subscription_tier: str = None) -> str:
950
+ """通过统一存储系统保存凭证"""
951
+ # 生成文件名(使用project_id和时间戳)
952
+ timestamp = int(time.time())
953
+
954
+ # antigravity模式使用特殊前缀
955
+ if mode == "antigravity":
956
+ filename = f"ag_{project_id}-{timestamp}.json"
957
+ else:
958
+ filename = f"{project_id}-{timestamp}.json"
959
+
960
+ # 准备凭证数据
961
+ creds_data = _prepare_credentials_data(creds, project_id, mode, subscription_tier)
962
+
963
+ # 通过存储适配器保存
964
+ storage_adapter = await get_storage_adapter()
965
+ success = await storage_adapter.store_credential(filename, creds_data, mode=mode)
966
+
967
+ if success:
968
+ # 创建默认状态记录
969
+ try:
970
+ default_state = {
971
+ "error_codes": [],
972
+ "disabled": False,
973
+ "last_success": time.time(),
974
+ "user_email": None,
975
+ "tier": subscription_tier,
976
+ }
977
+ await storage_adapter.update_credential_state(filename, default_state, mode=mode)
978
+ log.info(f"凭证和状态已保存到: {filename} (mode={mode})")
979
+ except Exception as e:
980
+ log.warning(f"创建默认状态记录失败 {filename}: {e}")
981
+
982
+ return filename
983
+ else:
984
+ raise Exception(f"保存凭证失败: {filename}")
985
+
986
+
987
+ def async_shutdown_server(server, port):
988
+ """异步关闭OAuth回调服务器,避免阻塞主流程"""
989
+
990
+ def shutdown_server_async():
991
+ try:
992
+ # 设置一个标志来跟踪关闭状态
993
+ shutdown_completed = threading.Event()
994
+
995
+ def do_shutdown():
996
+ try:
997
+ server.shutdown()
998
+ server.server_close()
999
+ shutdown_completed.set()
1000
+ log.info(f"已关闭端口 {port} 的OAuth回调服务器")
1001
+ except Exception as e:
1002
+ shutdown_completed.set()
1003
+ log.debug(f"关闭服务器时出错: {e}")
1004
+
1005
+ # 在单独线程中执行关闭操作
1006
+ shutdown_worker = threading.Thread(target=do_shutdown, daemon=True)
1007
+ shutdown_worker.start()
1008
+
1009
+ # 等待最多5秒,如果超时就放弃等待
1010
+ if shutdown_completed.wait(timeout=5):
1011
+ log.debug(f"端口 {port} 服务器关闭完成")
1012
+ else:
1013
+ log.warning(f"端口 {port} 服务器关闭超时,但不阻塞主流程")
1014
+
1015
+ except Exception as e:
1016
+ log.debug(f"异步关闭服务器时出错: {e}")
1017
+
1018
+ # 在后台线程中关闭服务器,不阻塞主流程
1019
+ shutdown_thread = threading.Thread(target=shutdown_server_async, daemon=True)
1020
+ shutdown_thread.start()
1021
+ log.debug(f"开始异步关闭端口 {port} 的OAuth回调服务器")
1022
+
1023
+
1024
+ def cleanup_expired_flows():
1025
+ """清理过期的认证流程"""
1026
+ current_time = time.time()
1027
+ EXPIRY_TIME = 600 # 10分钟过期
1028
+
1029
+ # 直接遍历删除,避免创建额外列表
1030
+ states_to_remove = [
1031
+ state
1032
+ for state, flow_data in auth_flows.items()
1033
+ if current_time - flow_data["created_at"] > EXPIRY_TIME
1034
+ ]
1035
+
1036
+ # 批量清理,提高效率
1037
+ cleaned_count = 0
1038
+ for state in states_to_remove:
1039
+ flow_data = auth_flows.get(state)
1040
+ if flow_data:
1041
+ # 快速��闭可能存在的服务器
1042
+ try:
1043
+ if flow_data.get("server"):
1044
+ server = flow_data["server"]
1045
+ port = flow_data.get("callback_port")
1046
+ async_shutdown_server(server, port)
1047
+ except Exception as e:
1048
+ log.debug(f"清理过期流程时启动异步关闭服务器失败: {e}")
1049
+
1050
+ # 显式清理流程数据,释放内存
1051
+ flow_data.clear()
1052
+ del auth_flows[state]
1053
+ cleaned_count += 1
1054
+
1055
+ if cleaned_count > 0:
1056
+ log.info(f"清理了 {cleaned_count} 个过期的认证流程")
1057
+
1058
+ # 更积极的垃圾回收触发条件
1059
+ if len(auth_flows) > 20: # 降低阈值
1060
+ import gc
1061
+
1062
+ gc.collect()
1063
+ log.debug(f"触发垃圾回收,当前活跃认证流程数: {len(auth_flows)}")
1064
+
1065
+
1066
+ def get_auth_status(project_id: str) -> Dict[str, Any]:
1067
+ """获取认证状态"""
1068
+ for state, flow_data in auth_flows.items():
1069
+ if flow_data["project_id"] == project_id:
1070
+ return {
1071
+ "status": "completed" if flow_data["completed"] else "pending",
1072
+ "state": state,
1073
+ "created_at": flow_data["created_at"],
1074
+ }
1075
+
1076
+ return {"status": "not_found"}
1077
+
1078
+
1079
+ # 鉴权功能 - 使用更小的数据结构
1080
+ auth_tokens = {} # 存储有效的认证令牌
1081
+ TOKEN_EXPIRY = 3600 # 1小时令牌过期时间
1082
+
1083
+
1084
+ async def verify_password(password: str) -> bool:
1085
+ """验证密码(面板登录使用)"""
1086
+ from config import get_panel_password
1087
+
1088
+ correct_password = await get_panel_password()
1089
+ return password == correct_password
src/converter/anthropic2gemini.py ADDED
@@ -0,0 +1,1260 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Anthropic 到 Gemini 格式转换器
3
+
4
+ 提供请求体、响应和流式转换的完整功能。
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import os
10
+ import uuid
11
+ from typing import Any, AsyncIterator, Dict, List, Optional
12
+
13
+ from fastapi import Response
14
+ from log import log
15
+ from src.converter.utils import merge_system_messages
16
+
17
+ from src.converter.thoughtSignature_fix import (
18
+ encode_tool_id_with_signature,
19
+ decode_tool_id_and_signature
20
+ )
21
+
22
+ DEFAULT_TEMPERATURE = 0.4
23
+ _DEBUG_TRUE = {"1", "true", "yes", "on"}
24
+
25
+ # ============================================================================
26
+ # Thinking 块验证和清理
27
+ # ============================================================================
28
+
29
+ # 最小有效签名长度
30
+ MIN_SIGNATURE_LENGTH = 10
31
+
32
+
33
+ def has_valid_thoughtsignature(block: Dict[str, Any]) -> bool:
34
+ """
35
+ 检查 thinking 块是否有有效签名
36
+
37
+ Args:
38
+ block: content block 字典
39
+
40
+ Returns:
41
+ bool: 是否有有效签名
42
+ """
43
+ if not isinstance(block, dict):
44
+ return True
45
+
46
+ block_type = block.get("type")
47
+ if block_type not in ("thinking", "redacted_thinking"):
48
+ return True # 非 thinking 块默认有效
49
+
50
+ thinking = block.get("thinking", "")
51
+ thoughtsignature = block.get("thoughtSignature")
52
+
53
+ # 空 thinking + 任意 thoughtsignature = 有效 (trailing signature case)
54
+ if not thinking and thoughtsignature is not None:
55
+ return True
56
+
57
+ # 有内容 + 足够长度的 thoughtsignature = 有效
58
+ if thoughtsignature and isinstance(thoughtsignature, str) and len(thoughtsignature) >= MIN_SIGNATURE_LENGTH:
59
+ return True
60
+
61
+ return False
62
+
63
+
64
+ def sanitize_thinking_block(block: Dict[str, Any]) -> Dict[str, Any]:
65
+ """
66
+ 清理 thinking 块,只保留必要字段(移除 cache_control 等)
67
+
68
+ Args:
69
+ block: content block 字典
70
+
71
+ Returns:
72
+ 清理后的 block 字典
73
+ """
74
+ if not isinstance(block, dict):
75
+ return block
76
+
77
+ block_type = block.get("type")
78
+ if block_type not in ("thinking", "redacted_thinking"):
79
+ return block
80
+
81
+ # 重建块,移除额外字段
82
+ sanitized: Dict[str, Any] = {
83
+ "type": block_type,
84
+ "thinking": block.get("thinking", "")
85
+ }
86
+
87
+ thoughtsignature = block.get("thoughtSignature")
88
+ if thoughtsignature:
89
+ sanitized["thoughtSignature"] = thoughtsignature
90
+
91
+ return sanitized
92
+
93
+
94
+ def remove_trailing_unsigned_thinking(blocks: List[Dict[str, Any]]) -> None:
95
+ """
96
+ 移除尾部的无签名 thinking 块
97
+
98
+ Args:
99
+ blocks: content blocks 列表 (会被修改)
100
+ """
101
+ if not blocks:
102
+ return
103
+
104
+ # 从后向前扫描
105
+ end_index = len(blocks)
106
+ for i in range(len(blocks) - 1, -1, -1):
107
+ block = blocks[i]
108
+ if not isinstance(block, dict):
109
+ break
110
+
111
+ block_type = block.get("type")
112
+ if block_type in ("thinking", "redacted_thinking"):
113
+ if not has_valid_thoughtsignature(block):
114
+ end_index = i
115
+ else:
116
+ break # 遇到有效签名的 thinking 块,停止
117
+ else:
118
+ break # 遇到非 thinking 块,停止
119
+
120
+ if end_index < len(blocks):
121
+ removed = len(blocks) - end_index
122
+ del blocks[end_index:]
123
+ log.debug(f"Removed {removed} trailing unsigned thinking block(s)")
124
+
125
+
126
+ def filter_invalid_thinking_blocks(messages: List[Dict[str, Any]]) -> None:
127
+ """
128
+ 过滤消息中的无效 thinking 块,并清理所有 thinking 块的额外字段(如 cache_control)
129
+
130
+ Args:
131
+ messages: Anthropic messages 列表 (会被修改)
132
+ """
133
+ total_filtered = 0
134
+
135
+ for msg in messages:
136
+ # 只处理 assistant 和 model 消息
137
+ role = msg.get("role", "")
138
+ if role not in ("assistant", "model"):
139
+ continue
140
+
141
+ content = msg.get("content")
142
+ if not isinstance(content, list):
143
+ continue
144
+
145
+ original_len = len(content)
146
+ new_blocks: List[Dict[str, Any]] = []
147
+
148
+ for block in content:
149
+ if not isinstance(block, dict):
150
+ new_blocks.append(block)
151
+ continue
152
+
153
+ block_type = block.get("type")
154
+ if block_type not in ("thinking", "redacted_thinking"):
155
+ new_blocks.append(block)
156
+ continue
157
+
158
+ # 所有 thinking 块都需要清理(移除 cache_control 等额外字段)
159
+ # 检查 thinking 块的有效性
160
+ if has_valid_thoughtsignature(block):
161
+ # 有效签名,清理后保留
162
+ new_blocks.append(sanitize_thinking_block(block))
163
+ else:
164
+ # 无效签名,将内容转换为 text 块
165
+ thinking_text = block.get("thinking", "")
166
+ if thinking_text and str(thinking_text).strip():
167
+ log.info(
168
+ f"[Claude-Handler] Converting thinking block with invalid thoughtSignature to text. "
169
+ f"Content length: {len(thinking_text)} chars"
170
+ )
171
+ new_blocks.append({"type": "text", "text": thinking_text})
172
+ else:
173
+ log.debug("[Claude-Handler] Dropping empty thinking block with invalid thoughtSignature")
174
+
175
+ msg["content"] = new_blocks
176
+ filtered_count = original_len - len(new_blocks)
177
+ total_filtered += filtered_count
178
+
179
+ # 如果过滤后为空,添加一个空文本块以保持消息有效
180
+ if not new_blocks:
181
+ msg["content"] = [{"type": "text", "text": ""}]
182
+
183
+ if total_filtered > 0:
184
+ log.debug(f"Filtered {total_filtered} invalid thinking block(s) from history")
185
+
186
+
187
+ # ============================================================================
188
+ # 请求验证和提取
189
+ # ============================================================================
190
+
191
+
192
+ def _anthropic_debug_enabled() -> bool:
193
+ """检查是否启用 Anthropic 调试模式"""
194
+ return str(os.getenv("ANTHROPIC_DEBUG", "true")).strip().lower() in _DEBUG_TRUE
195
+
196
+
197
+ def _is_non_whitespace_text(value: Any) -> bool:
198
+ """
199
+ 判断文本是否包含"非空白"内容。
200
+
201
+ 说明:下游(Antigravity/Claude 兼容层)会对纯 text 内容块做校验:
202
+ - text 不能为空字符串
203
+ - text 不能仅由空白字符(空格/换行/制表等)组成
204
+ """
205
+ if value is None:
206
+ return False
207
+ try:
208
+ return bool(str(value).strip())
209
+ except Exception:
210
+ return False
211
+
212
+
213
+ def _remove_nulls_for_tool_input(value: Any) -> Any:
214
+ """
215
+ 递归移除 dict/list 中值为 null/None 的字段/元素。
216
+
217
+ 背景:Roo/Kilo 在 Anthropic native tool 路径下,若收到 tool_use.input 中包含 null,
218
+ 可能会把 null 当作真实入参执行(例如"在 null 中搜索")。
219
+ """
220
+ if isinstance(value, dict):
221
+ cleaned: Dict[str, Any] = {}
222
+ for k, v in value.items():
223
+ if v is None:
224
+ continue
225
+ cleaned[k] = _remove_nulls_for_tool_input(v)
226
+ return cleaned
227
+
228
+ if isinstance(value, list):
229
+ cleaned_list = []
230
+ for item in value:
231
+ if item is None:
232
+ continue
233
+ cleaned_list.append(_remove_nulls_for_tool_input(item))
234
+ return cleaned_list
235
+
236
+ return value
237
+
238
+ # ============================================================================
239
+ # 2. JSON Schema 清理
240
+ # ============================================================================
241
+
242
+ def clean_json_schema(schema: Any) -> Any:
243
+ """
244
+ 清理 JSON Schema,移除下游不支持的字段,并把验证要求追加到 description。
245
+ """
246
+ if not isinstance(schema, dict):
247
+ return schema
248
+
249
+ # 下游不支持的字段
250
+ unsupported_keys = {
251
+ "$schema", "$id", "$ref", "$defs", "definitions", "title",
252
+ "example", "examples", "readOnly", "writeOnly", "default",
253
+ "exclusiveMaximum", "exclusiveMinimum", "oneOf", "anyOf", "allOf",
254
+ "const", "additionalItems", "contains", "patternProperties",
255
+ "dependencies", "propertyNames", "if", "then", "else",
256
+ "contentEncoding", "contentMediaType",
257
+ }
258
+
259
+ validation_fields = {
260
+ "minLength": "minLength",
261
+ "maxLength": "maxLength",
262
+ "minimum": "minimum",
263
+ "maximum": "maximum",
264
+ "minItems": "minItems",
265
+ "maxItems": "maxItems",
266
+ }
267
+ fields_to_remove = {"additionalProperties"}
268
+
269
+ validations: List[str] = []
270
+ for field, label in validation_fields.items():
271
+ if field in schema:
272
+ validations.append(f"{label}: {schema[field]}")
273
+
274
+ cleaned: Dict[str, Any] = {}
275
+ for key, value in schema.items():
276
+ if key in unsupported_keys or key in fields_to_remove or key in validation_fields:
277
+ continue
278
+
279
+ if key == "type" and isinstance(value, list):
280
+ # type: ["string", "null"] -> type: "string", nullable: true
281
+ has_null = any(
282
+ isinstance(t, str) and t.strip() and t.strip().lower() == "null" for t in value
283
+ )
284
+ non_null_types = [
285
+ t.strip()
286
+ for t in value
287
+ if isinstance(t, str) and t.strip() and t.strip().lower() != "null"
288
+ ]
289
+
290
+ cleaned[key] = non_null_types[0] if non_null_types else "string"
291
+ if has_null:
292
+ cleaned["nullable"] = True
293
+ continue
294
+
295
+ if key == "description" and validations:
296
+ cleaned[key] = f"{value} ({', '.join(validations)})"
297
+ elif isinstance(value, dict):
298
+ cleaned[key] = clean_json_schema(value)
299
+ elif isinstance(value, list):
300
+ cleaned[key] = [clean_json_schema(item) if isinstance(item, dict) else item for item in value]
301
+ else:
302
+ cleaned[key] = value
303
+
304
+ if validations and "description" not in cleaned:
305
+ cleaned["description"] = f"Validation: {', '.join(validations)}"
306
+
307
+ # 如果有 properties 但没有显式 type,则补齐为 object
308
+ if "properties" in cleaned and "type" not in cleaned:
309
+ cleaned["type"] = "object"
310
+
311
+ return cleaned
312
+
313
+
314
+ # ============================================================================
315
+ # 4. Tools 转换
316
+ # ============================================================================
317
+
318
+ def convert_tools(anthropic_tools: Optional[List[Dict[str, Any]]]) -> Optional[List[Dict[str, Any]]]:
319
+ """
320
+ 将 Anthropic tools[] 转换为下游 tools(functionDeclarations)结构。
321
+ """
322
+ if not anthropic_tools:
323
+ return None
324
+
325
+ gemini_tools: List[Dict[str, Any]] = []
326
+ for tool in anthropic_tools:
327
+ name = tool.get("name", "nameless_function")
328
+ description = tool.get("description", "")
329
+ input_schema = tool.get("input_schema", {}) or {}
330
+ parameters = clean_json_schema(input_schema)
331
+
332
+ gemini_tools.append(
333
+ {
334
+ "functionDeclarations": [
335
+ {
336
+ "name": name,
337
+ "description": description,
338
+ "parameters": parameters,
339
+ }
340
+ ]
341
+ }
342
+ )
343
+
344
+ return gemini_tools or None
345
+
346
+
347
+ # ============================================================================
348
+ # 5. Messages 转换
349
+ # ============================================================================
350
+
351
+ def _extract_tool_result_output(content: Any) -> str:
352
+ """从 tool_result.content 中提取输出字符串"""
353
+ if isinstance(content, list):
354
+ if not content:
355
+ return ""
356
+ first = content[0]
357
+ if isinstance(first, dict) and first.get("type") == "text":
358
+ return str(first.get("text", ""))
359
+ return str(first)
360
+ if content is None:
361
+ return ""
362
+ return str(content)
363
+
364
+
365
+ def convert_messages_to_contents(
366
+ messages: List[Dict[str, Any]],
367
+ *,
368
+ include_thinking: bool = True
369
+ ) -> List[Dict[str, Any]]:
370
+ """
371
+ 将 Anthropic messages[] 转换为下游 contents[](role: user/model, parts: [])。
372
+
373
+ Args:
374
+ messages: Anthropic 格式的消息列表
375
+ include_thinking: 是否包含 thinking 块
376
+ """
377
+ contents: List[Dict[str, Any]] = []
378
+
379
+ # 第一遍:构建 tool_use_id -> (name, thoughtsignature) 的映射
380
+ # 注意:存储的是编码后的 ID(可能包含签名)
381
+ tool_use_info: Dict[str, tuple[str, Optional[str]]] = {}
382
+ for msg in messages:
383
+ raw_content = msg.get("content", "")
384
+ if isinstance(raw_content, list):
385
+ for item in raw_content:
386
+ if isinstance(item, dict) and item.get("type") == "tool_use":
387
+ encoded_tool_id = item.get("id")
388
+ tool_name = item.get("name")
389
+ if encoded_tool_id and tool_name:
390
+ # 解码获取原始ID和签名
391
+ original_id, thoughtsignature = decode_tool_id_and_signature(encoded_tool_id)
392
+ # 存储映射:编码ID -> (name, thoughtsignature)
393
+ tool_use_info[str(encoded_tool_id)] = (tool_name, thoughtsignature)
394
+
395
+ for msg in messages:
396
+ role = msg.get("role", "user")
397
+
398
+ # system 消息已经由 merge_system_messages 处理,这里跳过
399
+ if role == "system":
400
+ continue
401
+
402
+ # 支持 'assistant' 和 'model' 角色(Google history usage)
403
+ gemini_role = "model" if role in ("assistant", "model") else "user"
404
+ raw_content = msg.get("content", "")
405
+
406
+ parts: List[Dict[str, Any]] = []
407
+ if isinstance(raw_content, str):
408
+ if _is_non_whitespace_text(raw_content):
409
+ parts = [{"text": str(raw_content)}]
410
+ elif isinstance(raw_content, list):
411
+ for item in raw_content:
412
+ if not isinstance(item, dict):
413
+ if _is_non_whitespace_text(item):
414
+ parts.append({"text": str(item)})
415
+ continue
416
+
417
+ item_type = item.get("type")
418
+ if item_type == "thinking":
419
+ if not include_thinking:
420
+ continue
421
+
422
+ thinking_text = item.get("thinking", "")
423
+ if thinking_text is None:
424
+ thinking_text = ""
425
+
426
+ part: Dict[str, Any] = {
427
+ "text": str(thinking_text),
428
+ "thought": True,
429
+ }
430
+
431
+ # 如果有 thoughtsignature 则添加
432
+ thoughtsignature = item.get("thoughtSignature")
433
+ if thoughtsignature:
434
+ part["thoughtSignature"] = thoughtsignature
435
+
436
+ parts.append(part)
437
+ elif item_type == "redacted_thinking":
438
+ if not include_thinking:
439
+ continue
440
+
441
+ thinking_text = item.get("thinking")
442
+ if thinking_text is None:
443
+ thinking_text = item.get("data", "")
444
+
445
+ part_dict: Dict[str, Any] = {
446
+ "text": str(thinking_text or ""),
447
+ "thought": True,
448
+ }
449
+
450
+ # 如果有 thoughtsignature 则添加
451
+ thoughtsignature = item.get("thoughtSignature")
452
+ if thoughtsignature:
453
+ part_dict["thoughtSignature"] = thoughtsignature
454
+
455
+ parts.append(part_dict)
456
+ elif item_type == "text":
457
+ text = item.get("text", "")
458
+ if _is_non_whitespace_text(text):
459
+ parts.append({"text": str(text)})
460
+ elif item_type == "image":
461
+ source = item.get("source", {}) or {}
462
+ if source.get("type") == "base64":
463
+ parts.append(
464
+ {
465
+ "inlineData": {
466
+ "mimeType": source.get("media_type", "image/png"),
467
+ "data": source.get("data", ""),
468
+ }
469
+ }
470
+ )
471
+ elif item_type == "tool_use":
472
+ encoded_id = item.get("id") or ""
473
+ original_id, thoughtsignature = decode_tool_id_and_signature(encoded_id)
474
+
475
+ fc_part: Dict[str, Any] = {
476
+ "functionCall": {
477
+ "id": original_id, # 使用原始ID,不带签名
478
+ "name": item.get("name"),
479
+ "args": item.get("input", {}) or {},
480
+ }
481
+ }
482
+
483
+ # 如果提取到签名则添加,否则使用占位符以满足 Gemini API 要求
484
+ if thoughtsignature:
485
+ fc_part["thoughtSignature"] = thoughtsignature
486
+ else:
487
+ fc_part["thoughtSignature"] = "skip_thought_signature_validator"
488
+
489
+ parts.append(fc_part)
490
+ elif item_type == "tool_result":
491
+ output = _extract_tool_result_output(item.get("content"))
492
+ encoded_tool_use_id = item.get("tool_use_id") or ""
493
+
494
+ # 解码获取原始ID(functionResponse不需要签名)
495
+ original_tool_use_id, _ = decode_tool_id_and_signature(encoded_tool_use_id)
496
+
497
+ # 从 tool_result 获取 name,如果没有则从映射中查找
498
+ func_name = item.get("name")
499
+ if not func_name and encoded_tool_use_id:
500
+ # 使用编码ID查找映射
501
+ tool_info = tool_use_info.get(str(encoded_tool_use_id))
502
+ if tool_info:
503
+ func_name = tool_info[0] # 获取 name
504
+ if not func_name:
505
+ func_name = "unknown_function"
506
+
507
+ parts.append(
508
+ {
509
+ "functionResponse": {
510
+ "id": original_tool_use_id, # 使用解码后的原始ID以匹配functionCall
511
+ "name": func_name,
512
+ "response": {"output": output},
513
+ }
514
+ }
515
+ )
516
+ else:
517
+ parts.append({"text": json.dumps(item, ensure_ascii=False)})
518
+ else:
519
+ if _is_non_whitespace_text(raw_content):
520
+ parts = [{"text": str(raw_content)}]
521
+
522
+ if not parts:
523
+ continue
524
+
525
+ contents.append({"role": gemini_role, "parts": parts})
526
+
527
+ return contents
528
+
529
+
530
+ def reorganize_tool_messages(contents: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
531
+ """
532
+ 重新组织消息,满足 tool_use/tool_result 约束。
533
+ """
534
+ tool_results: Dict[str, Dict[str, Any]] = {}
535
+
536
+ for msg in contents:
537
+ for part in msg.get("parts", []) or []:
538
+ if isinstance(part, dict) and "functionResponse" in part:
539
+ tool_id = (part.get("functionResponse") or {}).get("id")
540
+ if tool_id:
541
+ tool_results[str(tool_id)] = part
542
+
543
+ flattened: List[Dict[str, Any]] = []
544
+ for msg in contents:
545
+ role = msg.get("role")
546
+ for part in msg.get("parts", []) or []:
547
+ flattened.append({"role": role, "parts": [part]})
548
+
549
+ new_contents: List[Dict[str, Any]] = []
550
+ i = 0
551
+ while i < len(flattened):
552
+ msg = flattened[i]
553
+ part = msg["parts"][0]
554
+
555
+ if isinstance(part, dict) and "functionResponse" in part:
556
+ i += 1
557
+ continue
558
+
559
+ if isinstance(part, dict) and "functionCall" in part:
560
+ tool_id = (part.get("functionCall") or {}).get("id")
561
+ new_contents.append({"role": "model", "parts": [part]})
562
+
563
+ if tool_id is not None and str(tool_id) in tool_results:
564
+ new_contents.append({"role": "user", "parts": [tool_results[str(tool_id)]]})
565
+
566
+ i += 1
567
+ continue
568
+
569
+ new_contents.append(msg)
570
+ i += 1
571
+
572
+ return new_contents
573
+
574
+
575
+ # ============================================================================
576
+ # 7. Tool Choice 转换
577
+ # ============================================================================
578
+
579
+ def convert_tool_choice_to_tool_config(tool_choice: Any) -> Optional[Dict[str, Any]]:
580
+ """
581
+ 将 Anthropic tool_choice 转换为 Gemini toolConfig
582
+
583
+ Args:
584
+ tool_choice: Anthropic 格式的 tool_choice
585
+ - {"type": "auto"}: 模型自动决定是否使用工具
586
+ - {"type": "any"}: 模型必须使用工具
587
+ - {"type": "tool", "name": "tool_name"}: 模型必须使用指定工具
588
+
589
+ Returns:
590
+ Gemini 格式的 toolConfig,如果无效则返回 None
591
+ """
592
+ if not tool_choice:
593
+ return None
594
+
595
+ if isinstance(tool_choice, dict):
596
+ choice_type = tool_choice.get("type")
597
+
598
+ if choice_type == "auto":
599
+ return {"functionCallingConfig": {"mode": "AUTO"}}
600
+ elif choice_type == "any":
601
+ return {"functionCallingConfig": {"mode": "ANY"}}
602
+ elif choice_type == "tool":
603
+ tool_name = tool_choice.get("name")
604
+ if tool_name:
605
+ return {
606
+ "functionCallingConfig": {
607
+ "mode": "ANY",
608
+ "allowedFunctionNames": [tool_name],
609
+ }
610
+ }
611
+
612
+ # 无效或不支持的 tool_choice,返回 None
613
+ return None
614
+
615
+
616
+ # ============================================================================
617
+ # 8. Generation Config 构建
618
+ # ============================================================================
619
+
620
+ def build_generation_config(payload: Dict[str, Any]) -> Dict[str, Any]:
621
+ """
622
+ 根据 Anthropic Messages 请求构造下游 generationConfig。
623
+
624
+ Returns:
625
+ generation_config: 生成配置字典
626
+ """
627
+ config: Dict[str, Any] = {
628
+ "topP": 1,
629
+ "candidateCount": 1,
630
+ "stopSequences": [
631
+ "<|user|>",
632
+ "<|bot|>",
633
+ "<|context_request|>",
634
+ "<|endoftext|>",
635
+ "<|end_of_turn|>",
636
+ ],
637
+ }
638
+
639
+ temperature = payload.get("temperature", None)
640
+ config["temperature"] = DEFAULT_TEMPERATURE if temperature is None else temperature
641
+
642
+ top_p = payload.get("top_p", None)
643
+ if top_p is not None:
644
+ config["topP"] = top_p
645
+
646
+ top_k = payload.get("top_k", None)
647
+ if top_k is not None:
648
+ config["topK"] = top_k
649
+
650
+ max_tokens = payload.get("max_tokens")
651
+ if max_tokens is not None:
652
+ config["maxOutputTokens"] = max_tokens
653
+
654
+ # 处理 extended thinking 参数 (plan mode)
655
+ thinking = payload.get("thinking")
656
+ is_plan_mode = False
657
+ if thinking and isinstance(thinking, dict):
658
+ thinking_type = thinking.get("type")
659
+ budget_tokens = thinking.get("budget_tokens")
660
+
661
+ # 如果启用了 extended thinking,设置 thinkingConfig
662
+ if thinking_type == "enabled":
663
+ is_plan_mode = True
664
+ thinking_config: Dict[str, Any] = {}
665
+
666
+ # 设置思考预算,默认使用较大的值以支持计划模式
667
+ if budget_tokens is not None:
668
+ thinking_config["thinkingBudget"] = budget_tokens
669
+ else:
670
+ # 默认给一个较大的思考预算以支持完整的计划生成
671
+ thinking_config["thinkingBudget"] = 48000
672
+
673
+ # 始终包含思考内容,这样才能看到计划
674
+ thinking_config["includeThoughts"] = True
675
+
676
+ config["thinkingConfig"] = thinking_config
677
+ log.info(f"[ANTHROPIC2GEMINI] Extended thinking enabled with budget: {thinking_config['thinkingBudget']}")
678
+ elif thinking_type == "disabled":
679
+ # 明确禁用思考模式
680
+ config["thinkingConfig"] = {
681
+ "includeThoughts": False
682
+ }
683
+ log.info("[ANTHROPIC2GEMINI] Extended thinking explicitly disabled")
684
+
685
+ stop_sequences = payload.get("stop_sequences")
686
+ if isinstance(stop_sequences, list) and stop_sequences:
687
+ config["stopSequences"] = config["stopSequences"] + [str(s) for s in stop_sequences]
688
+ elif is_plan_mode:
689
+ # Plan mode 时清空默认 stop sequences,避免过早停止
690
+ # 默认的 stop sequences 可能会导致模型在生成计划时过早停止
691
+ config["stopSequences"] = []
692
+ log.info("[ANTHROPIC2GEMINI] Plan mode: cleared default stop sequences to prevent premature stopping")
693
+
694
+ # 如果不是 plan mode 且没有自定义 stop_sequences,保持默认值
695
+ # (默认值已经在 config 初始化时设置)
696
+
697
+ return config
698
+
699
+
700
+ # ============================================================================
701
+ # 8. 主要转换函数
702
+ # ============================================================================
703
+
704
+ async def anthropic_to_gemini_request(payload: Dict[str, Any]) -> Dict[str, Any]:
705
+ """
706
+ 将 Anthropic 格式请求体转换为 Gemini 格式请求体
707
+
708
+ 注意: 此函数只负责基础转换,不包含 normalize_gemini_request 中的处理
709
+ (如 thinking config 自动设置、search tools、参数范围限制等)
710
+
711
+ Args:
712
+ payload: Anthropic 格式的请求体字典
713
+
714
+ Returns:
715
+ Gemini 格式的请求体字典,包含:
716
+ - contents: 转换后的消息内容
717
+ - generationConfig: 生成配置
718
+ - systemInstruction: 系统指令 (如果有)
719
+ - tools: 工具定义 (如果有)
720
+ - toolConfig: 工具调用配置 (如果有 tool_choice)
721
+ """
722
+ # 处理连续的system消息(兼容性模式)
723
+ payload = await merge_system_messages(payload)
724
+
725
+ # 提取和转换基础信息
726
+ messages = payload.get("messages") or []
727
+ if not isinstance(messages, list):
728
+ messages = []
729
+
730
+ # [CRITICAL FIX] 过滤并修复 Thinking 块签名
731
+ # 在转换前先过滤无效的 thinking 块
732
+ filter_invalid_thinking_blocks(messages)
733
+
734
+ # 构建生成配置
735
+ generation_config = build_generation_config(payload)
736
+
737
+ # 转换消息内容(始终包含thinking块,由响应端处理)
738
+ contents = convert_messages_to_contents(messages, include_thinking=True)
739
+
740
+ # [CRITICAL FIX] 移除尾部无签名的 thinking 块
741
+ # 对真实请求应用额外的清理
742
+ for content in contents:
743
+ role = content.get("role", "")
744
+ if role == "model": # 只处理 model/assistant 消息
745
+ parts = content.get("parts", [])
746
+ if isinstance(parts, list):
747
+ remove_trailing_unsigned_thinking(parts)
748
+
749
+ contents = reorganize_tool_messages(contents)
750
+
751
+ # 转换工具
752
+ tools = convert_tools(payload.get("tools"))
753
+
754
+ # 转换 tool_choice
755
+ tool_config = convert_tool_choice_to_tool_config(payload.get("tool_choice"))
756
+
757
+ # 构建基础请求数据
758
+ gemini_request = {
759
+ "contents": contents,
760
+ "generationConfig": generation_config,
761
+ }
762
+
763
+ # 如果 merge_system_messages 已经添加了 systemInstruction,使用它
764
+ if "systemInstruction" in payload:
765
+ gemini_request["systemInstruction"] = payload["systemInstruction"]
766
+
767
+ if tools:
768
+ gemini_request["tools"] = tools
769
+
770
+ # 添加 toolConfig(如果有 tool_choice)
771
+ if tool_config:
772
+ gemini_request["toolConfig"] = tool_config
773
+
774
+ return gemini_request
775
+
776
+
777
+ def gemini_to_anthropic_response(
778
+ gemini_response: Dict[str, Any],
779
+ model: str,
780
+ status_code: int = 200
781
+ ) -> Dict[str, Any]:
782
+ """
783
+ 将 Gemini 格式非流式响应转换为 Anthropic 格式非流式响应
784
+
785
+ 注意: 如果收到的不是 200 开头的响应体,不做任何处理,直接转发
786
+
787
+ Args:
788
+ gemini_response: Gemini 格式的响应体字典
789
+ model: 模型名称
790
+ status_code: HTTP 状态码 (默认 200)
791
+
792
+ Returns:
793
+ Anthropic 格式的响应体字典,或原始响应 (如果状态码不是 2xx)
794
+ """
795
+ # 非 2xx 状态码直接返回原始响应
796
+ if not (200 <= status_code < 300):
797
+ return gemini_response
798
+
799
+ # 处理 GeminiCLI 的 response 包装格式
800
+ if "response" in gemini_response:
801
+ response_data = gemini_response["response"]
802
+ else:
803
+ response_data = gemini_response
804
+
805
+ # 提取候选结果
806
+ candidate = response_data.get("candidates", [{}])[0] or {}
807
+ parts = candidate.get("content", {}).get("parts", []) or []
808
+
809
+ # 获取 usage metadata
810
+ usage_metadata = {}
811
+ if "usageMetadata" in response_data:
812
+ usage_metadata = response_data["usageMetadata"]
813
+ elif "usageMetadata" in candidate:
814
+ usage_metadata = candidate["usageMetadata"]
815
+
816
+ # 转换内容块
817
+ content = []
818
+ has_tool_use = False
819
+
820
+ for part in parts:
821
+ if not isinstance(part, dict):
822
+ continue
823
+
824
+ # 处理 thinking 块
825
+ if part.get("thought") is True:
826
+ thinking_text = part.get("text", "")
827
+ if thinking_text is None:
828
+ thinking_text = ""
829
+
830
+ block: Dict[str, Any] = {"type": "thinking", "thinking": str(thinking_text)}
831
+
832
+ # 如果有 thoughtsignature 则添加
833
+ thoughtsignature = part.get("thoughtSignature")
834
+ if thoughtsignature:
835
+ block["thoughtSignature"] = thoughtsignature
836
+
837
+ content.append(block)
838
+ continue
839
+
840
+ # 处理文本块
841
+ if "text" in part:
842
+ content.append({"type": "text", "text": part.get("text", "")})
843
+ continue
844
+
845
+ # 处理工具调用
846
+ if "functionCall" in part:
847
+ has_tool_use = True
848
+ fc = part.get("functionCall", {}) or {}
849
+ original_id = fc.get("id") or f"toolu_{uuid.uuid4().hex}"
850
+ thoughtsignature = part.get("thoughtSignature")
851
+
852
+ # 对工具调用ID进行签名编码
853
+ encoded_id = encode_tool_id_with_signature(original_id, thoughtsignature)
854
+ content.append(
855
+ {
856
+ "type": "tool_use",
857
+ "id": encoded_id,
858
+ "name": fc.get("name") or "",
859
+ "input": _remove_nulls_for_tool_input(fc.get("args", {}) or {}),
860
+ }
861
+ )
862
+ continue
863
+
864
+ # 处理图片
865
+ if "inlineData" in part:
866
+ inline = part.get("inlineData", {}) or {}
867
+ content.append(
868
+ {
869
+ "type": "image",
870
+ "source": {
871
+ "type": "base64",
872
+ "media_type": inline.get("mimeType", "image/png"),
873
+ "data": inline.get("data", ""),
874
+ },
875
+ }
876
+ )
877
+ continue
878
+
879
+ # 确定停止原因
880
+ finish_reason = candidate.get("finishReason")
881
+
882
+ # 只有在正常停止(STOP)且有工具调用时才设为 tool_use
883
+ # 避免在 SAFETY、MAX_TOKENS 等情况下仍然返回 tool_use 导致循环
884
+ if has_tool_use and finish_reason == "STOP":
885
+ stop_reason = "tool_use"
886
+ elif finish_reason == "MAX_TOKENS":
887
+ stop_reason = "max_tokens"
888
+ else:
889
+ # 其他情况(SAFETY、RECITATION 等)默认为 end_turn
890
+ stop_reason = "end_turn"
891
+
892
+ # 提取 token 使用情况
893
+ input_tokens = usage_metadata.get("promptTokenCount", 0) if isinstance(usage_metadata, dict) else 0
894
+ output_tokens = usage_metadata.get("candidatesTokenCount", 0) if isinstance(usage_metadata, dict) else 0
895
+
896
+ # 构建 Anthropic 响应
897
+ message_id = f"msg_{uuid.uuid4().hex}"
898
+
899
+ return {
900
+ "id": message_id,
901
+ "type": "message",
902
+ "role": "assistant",
903
+ "model": model,
904
+ "content": content,
905
+ "stop_reason": stop_reason,
906
+ "stop_sequence": None,
907
+ "usage": {
908
+ "input_tokens": int(input_tokens or 0),
909
+ "output_tokens": int(output_tokens or 0),
910
+ },
911
+ }
912
+
913
+
914
+ async def gemini_stream_to_anthropic_stream(
915
+ gemini_stream: AsyncIterator[bytes],
916
+ model: str,
917
+ status_code: int = 200
918
+ ) -> AsyncIterator[bytes]:
919
+ """
920
+ 将 Gemini 格式流式响应转换为 Anthropic SSE 格式流式响应
921
+
922
+ 注意: 如果收到的不是 200 开头的响应体,不做任何处理,直接转发
923
+
924
+ Args:
925
+ gemini_stream: Gemini 格式的流式响应 (bytes 迭代器)
926
+ model: 模型名称
927
+ status_code: HTTP 状态码 (默认 200)
928
+
929
+ Yields:
930
+ Anthropic SSE 格式的响应块 (bytes)
931
+ """
932
+ # 非 2xx 状态码直接转发原始流
933
+ if not (200 <= status_code < 300):
934
+ async for chunk in gemini_stream:
935
+ yield chunk
936
+ return
937
+
938
+ # 初始化状态
939
+ message_id = f"msg_{uuid.uuid4().hex}"
940
+ message_start_sent = False
941
+ current_block_type: Optional[str] = None
942
+ current_block_index = -1
943
+ current_thinking_signature: Optional[str] = None
944
+ has_tool_use = False
945
+ input_tokens = 0
946
+ output_tokens = 0
947
+ finish_reason: Optional[str] = None
948
+
949
+ def _sse_event(event: str, data: Dict[str, Any]) -> bytes:
950
+ """生成 SSE 事件"""
951
+ payload = json.dumps(data, ensure_ascii=False, separators=(",", ":"))
952
+ return f"event: {event}\ndata: {payload}\n\n".encode("utf-8")
953
+
954
+ def _close_block() -> Optional[bytes]:
955
+ """关闭当前内容块"""
956
+ nonlocal current_block_type
957
+ if current_block_type is None:
958
+ return None
959
+ event = _sse_event(
960
+ "content_block_stop",
961
+ {"type": "content_block_stop", "index": current_block_index},
962
+ )
963
+ current_block_type = None
964
+ return event
965
+
966
+ # 处理流式数据
967
+ try:
968
+ async for chunk in gemini_stream:
969
+ # 检查是否是 Response 对象(错误情况)
970
+ if isinstance(chunk, Response):
971
+ log.warning(f"[GEMINI_TO_ANTHROPIC] 收到 Response 对象,状态码: {chunk.status_code},直接转发错误")
972
+ # 直接转发错误响应内容,不做格式转换
973
+ error_content = chunk.body if isinstance(chunk.body, bytes) else chunk.body.encode('utf-8')
974
+ yield error_content
975
+ return
976
+
977
+ # 记录接收到的原始chunk
978
+ log.debug(f"[GEMINI_TO_ANTHROPIC] Raw chunk: {chunk[:200] if chunk else b''}")
979
+
980
+ # 解析 Gemini 流式块
981
+ if not chunk or not chunk.startswith(b"data: "):
982
+ log.debug(f"[GEMINI_TO_ANTHROPIC] Skipping chunk (not SSE format or empty)")
983
+ continue
984
+
985
+ raw = chunk[6:].strip()
986
+ if raw == b"[DONE]":
987
+ log.debug(f"[GEMINI_TO_ANTHROPIC] Received [DONE] marker")
988
+ break
989
+
990
+ log.debug(f"[GEMINI_TO_ANTHROPIC] Parsing JSON: {raw[:200]}")
991
+
992
+ try:
993
+ data = json.loads(raw.decode('utf-8', errors='ignore'))
994
+ log.debug(f"[GEMINI_TO_ANTHROPIC] Parsed data: {json.dumps(data, ensure_ascii=False)[:300]}")
995
+ except Exception as e:
996
+ log.warning(f"[GEMINI_TO_ANTHROPIC] JSON parse error: {e}")
997
+ continue
998
+
999
+ # 处理 GeminiCLI 的 response 包装格式
1000
+ if "response" in data:
1001
+ response = data["response"]
1002
+ else:
1003
+ response = data
1004
+
1005
+ candidate = (response.get("candidates", []) or [{}])[0] or {}
1006
+ parts = (candidate.get("content", {}) or {}).get("parts", []) or []
1007
+
1008
+ # 更新 usage metadata
1009
+ if "usageMetadata" in response:
1010
+ usage = response["usageMetadata"]
1011
+ if isinstance(usage, dict):
1012
+ if "promptTokenCount" in usage:
1013
+ input_tokens = int(usage.get("promptTokenCount", 0) or 0)
1014
+ if "candidatesTokenCount" in usage:
1015
+ output_tokens = int(usage.get("candidatesTokenCount", 0) or 0)
1016
+
1017
+ # 发送 message_start(仅一次)
1018
+ if not message_start_sent:
1019
+ message_start_sent = True
1020
+ yield _sse_event(
1021
+ "message_start",
1022
+ {
1023
+ "type": "message_start",
1024
+ "message": {
1025
+ "id": message_id,
1026
+ "type": "message",
1027
+ "role": "assistant",
1028
+ "model": model,
1029
+ "content": [],
1030
+ "stop_reason": None,
1031
+ "stop_sequence": None,
1032
+ "usage": {"input_tokens": input_tokens, "output_tokens": output_tokens},
1033
+ },
1034
+ },
1035
+ )
1036
+
1037
+ # 处理各种 parts
1038
+ for part in parts:
1039
+ if not isinstance(part, dict):
1040
+ continue
1041
+
1042
+ # 处理 thinking 块
1043
+ if part.get("thought") is True:
1044
+ thinking_text = part.get("text", "")
1045
+ thoughtsignature = part.get("thoughtSignature")
1046
+
1047
+ # 检查是否需要关闭上一个块并开启新的 thinking 块
1048
+ if current_block_type != "thinking":
1049
+ close_evt = _close_block()
1050
+ if close_evt:
1051
+ yield close_evt
1052
+
1053
+ current_block_index += 1
1054
+ current_block_type = "thinking"
1055
+ current_thinking_signature = thoughtsignature
1056
+
1057
+ block: Dict[str, Any] = {"type": "thinking", "thinking": ""}
1058
+ if thoughtsignature:
1059
+ block["thoughtSignature"] = thoughtsignature
1060
+ yield _sse_event(
1061
+ "content_block_start",
1062
+ {
1063
+ "type": "content_block_start",
1064
+ "index": current_block_index,
1065
+ "content_block": block,
1066
+ },
1067
+ )
1068
+ elif thoughtsignature and thoughtsignature != current_thinking_signature:
1069
+ # 签名变化,需要开启新的 thinking 块
1070
+ close_evt = _close_block()
1071
+ if close_evt:
1072
+ yield close_evt
1073
+
1074
+ current_block_index += 1
1075
+ current_block_type = "thinking"
1076
+ current_thinking_signature = thoughtsignature
1077
+
1078
+ block_new: Dict[str, Any] = {"type": "thinking", "thinking": ""}
1079
+ if thoughtsignature:
1080
+ block_new["thoughtSignature"] = thoughtsignature
1081
+
1082
+ yield _sse_event(
1083
+ "content_block_start",
1084
+ {
1085
+ "type": "content_block_start",
1086
+ "index": current_block_index,
1087
+ "content_block": block_new,
1088
+ },
1089
+ )
1090
+
1091
+ # 发送 thinking 文本增量
1092
+ if thinking_text:
1093
+ yield _sse_event(
1094
+ "content_block_delta",
1095
+ {
1096
+ "type": "content_block_delta",
1097
+ "index": current_block_index,
1098
+ "delta": {"type": "thinking_delta", "thinking": thinking_text},
1099
+ },
1100
+ )
1101
+ continue
1102
+
1103
+ # 处理文本块
1104
+ if "text" in part:
1105
+ text = part.get("text", "")
1106
+ if isinstance(text, str) and not text.strip():
1107
+ continue
1108
+
1109
+ if current_block_type != "text":
1110
+ close_evt = _close_block()
1111
+ if close_evt:
1112
+ yield close_evt
1113
+
1114
+ current_block_index += 1
1115
+ current_block_type = "text"
1116
+
1117
+ yield _sse_event(
1118
+ "content_block_start",
1119
+ {
1120
+ "type": "content_block_start",
1121
+ "index": current_block_index,
1122
+ "content_block": {"type": "text", "text": ""},
1123
+ },
1124
+ )
1125
+
1126
+ if text:
1127
+ yield _sse_event(
1128
+ "content_block_delta",
1129
+ {
1130
+ "type": "content_block_delta",
1131
+ "index": current_block_index,
1132
+ "delta": {"type": "text_delta", "text": text},
1133
+ },
1134
+ )
1135
+ continue
1136
+
1137
+ # 处理工具调用
1138
+ if "functionCall" in part:
1139
+ close_evt = _close_block()
1140
+ if close_evt:
1141
+ yield close_evt
1142
+
1143
+ has_tool_use = True
1144
+ fc = part.get("functionCall", {}) or {}
1145
+ original_id = fc.get("id") or f"toolu_{uuid.uuid4().hex}"
1146
+ thoughtsignature = part.get("thoughtSignature")
1147
+ tool_id = encode_tool_id_with_signature(original_id, thoughtsignature)
1148
+ tool_name = fc.get("name") or ""
1149
+ tool_args = _remove_nulls_for_tool_input(fc.get("args", {}) or {})
1150
+
1151
+ if _anthropic_debug_enabled():
1152
+ log.info(
1153
+ f"[ANTHROPIC][tool_use] 处理工具调用: name={tool_name}, "
1154
+ f"id={tool_id}, has_signature={thoughtsignature is not None}"
1155
+ )
1156
+
1157
+ current_block_index += 1
1158
+ # 注意:工具调用不设置 current_block_type,因为它是独立完整的块
1159
+
1160
+ yield _sse_event(
1161
+ "content_block_start",
1162
+ {
1163
+ "type": "content_block_start",
1164
+ "index": current_block_index,
1165
+ "content_block": {
1166
+ "type": "tool_use",
1167
+ "id": tool_id,
1168
+ "name": tool_name,
1169
+ "input": {},
1170
+ },
1171
+ },
1172
+ )
1173
+
1174
+ input_json = json.dumps(tool_args, ensure_ascii=False, separators=(",", ":"))
1175
+ yield _sse_event(
1176
+ "content_block_delta",
1177
+ {
1178
+ "type": "content_block_delta",
1179
+ "index": current_block_index,
1180
+ "delta": {"type": "input_json_delta", "partial_json": input_json},
1181
+ },
1182
+ )
1183
+
1184
+ yield _sse_event(
1185
+ "content_block_stop",
1186
+ {"type": "content_block_stop", "index": current_block_index},
1187
+ )
1188
+ # 工具调用块已完全关闭,current_block_type 保持为 None
1189
+
1190
+ if _anthropic_debug_enabled():
1191
+ log.info(f"[ANTHROPIC][tool_use] 工具调用块已关闭: index={current_block_index}")
1192
+
1193
+ continue
1194
+
1195
+ # 检查是否结束
1196
+ if candidate.get("finishReason"):
1197
+ finish_reason = candidate.get("finishReason")
1198
+ break
1199
+
1200
+ # 关闭最后的内容块
1201
+ close_evt = _close_block()
1202
+ if close_evt:
1203
+ yield close_evt
1204
+
1205
+ # 确定停止原因
1206
+ # 只有在正常停止(STOP)且有工具调用时才设为 tool_use
1207
+ # 避免在 SAFETY、MAX_TOKENS 等情况下仍然返回 tool_use 导致循环
1208
+ if has_tool_use and finish_reason == "STOP":
1209
+ stop_reason = "tool_use"
1210
+ elif finish_reason == "MAX_TOKENS":
1211
+ stop_reason = "max_tokens"
1212
+ else:
1213
+ # 其他情况(SAFETY、RECITATION 等)默认为 end_turn
1214
+ stop_reason = "end_turn"
1215
+
1216
+ if _anthropic_debug_enabled():
1217
+ log.info(
1218
+ f"[ANTHROPIC][stream_end] 流式结束: stop_reason={stop_reason}, "
1219
+ f"has_tool_use={has_tool_use}, finish_reason={finish_reason}, "
1220
+ f"input_tokens={input_tokens}, output_tokens={output_tokens}"
1221
+ )
1222
+
1223
+ # 发送 message_delta 和 message_stop
1224
+ yield _sse_event(
1225
+ "message_delta",
1226
+ {
1227
+ "type": "message_delta",
1228
+ "delta": {"stop_reason": stop_reason, "stop_sequence": None},
1229
+ "usage": {
1230
+ "output_tokens": output_tokens,
1231
+ },
1232
+ },
1233
+ )
1234
+
1235
+ yield _sse_event("message_stop", {"type": "message_stop"})
1236
+
1237
+ except Exception as e:
1238
+ log.error(f"[ANTHROPIC] 流式转换失败: {e}")
1239
+ # 发送错误事件
1240
+ if not message_start_sent:
1241
+ yield _sse_event(
1242
+ "message_start",
1243
+ {
1244
+ "type": "message_start",
1245
+ "message": {
1246
+ "id": message_id,
1247
+ "type": "message",
1248
+ "role": "assistant",
1249
+ "model": model,
1250
+ "content": [],
1251
+ "stop_reason": None,
1252
+ "stop_sequence": None,
1253
+ "usage": {"input_tokens": input_tokens, "output_tokens": output_tokens},
1254
+ },
1255
+ },
1256
+ )
1257
+ yield _sse_event(
1258
+ "error",
1259
+ {"type": "error", "error": {"type": "api_error", "message": str(e)}},
1260
+ )
src/converter/anti_truncation.py ADDED
@@ -0,0 +1,731 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Anti-Truncation Module - Ensures complete streaming output
3
+ 保持一个流式请求内完整输出的反截断模块
4
+ """
5
+
6
+ import io
7
+ import json
8
+ import re
9
+ from typing import Any, AsyncGenerator, Dict, List, Tuple
10
+
11
+ from fastapi.responses import StreamingResponse
12
+
13
+ from log import log
14
+
15
+ # 反截断配置
16
+ DONE_MARKER = "[done]"
17
+ CONTINUATION_PROMPT = f"""请从刚才被截断的地方继续输出剩余的所有内容。
18
+
19
+ 重要提醒:
20
+ 1. 不要重复前面已经输出的内容
21
+ 2. 直接继续输出,无需任何前言或解释
22
+ 3. 当你完整完成所有内容输出后,必须在最后一行单独输出:{DONE_MARKER}
23
+ 4. {DONE_MARKER} 标记表示你的回答已经完全结束,这是必需的结束标记
24
+
25
+ 现在请继续输出:"""
26
+
27
+ # 正则替换配置
28
+ REGEX_REPLACEMENTS: List[Tuple[str, str, str]] = [
29
+ (
30
+ "age_pattern", # 替换规则名称
31
+ r"(?:[1-9]|1[0-8])岁(?:的)?|(?:十一|十二|十三|十四|十五|十六|十七|十八|十|一|二|三|四|五|六|七|八|九)岁(?:的)?", # 正则模式
32
+ "", # 替换文本
33
+ ),
34
+ # 可在此处添加更多替换规则
35
+ # ("rule_name", r"pattern", "replacement"),
36
+ ]
37
+
38
+
39
+ def apply_regex_replacements(text: str) -> str:
40
+ """
41
+ 对文本应用正则替换规则
42
+
43
+ Args:
44
+ text: 要处理的文本
45
+
46
+ Returns:
47
+ 处理后的文本
48
+ """
49
+ if not text:
50
+ return text
51
+
52
+ processed_text = text
53
+ replacement_count = 0
54
+
55
+ for rule_name, pattern, replacement in REGEX_REPLACEMENTS:
56
+ try:
57
+ # 编译正则表达式,使用IGNORECASE标志
58
+ regex = re.compile(pattern, re.IGNORECASE)
59
+
60
+ # 执行替换
61
+ new_text, count = regex.subn(replacement, processed_text)
62
+
63
+ if count > 0:
64
+ log.debug(f"Regex replacement '{rule_name}': {count} matches replaced")
65
+ processed_text = new_text
66
+ replacement_count += count
67
+
68
+ except re.error as e:
69
+ log.error(f"Invalid regex pattern in rule '{rule_name}': {e}")
70
+ continue
71
+
72
+ if replacement_count > 0:
73
+ log.info(f"Applied {replacement_count} regex replacements to text")
74
+
75
+ return processed_text
76
+
77
+
78
+ def apply_regex_replacements_to_payload(payload: Dict[str, Any]) -> Dict[str, Any]:
79
+ """
80
+ 对请求payload中的文本内容应用正则替换
81
+
82
+ Args:
83
+ payload: 请求payload
84
+
85
+ Returns:
86
+ 应用替换后的payload
87
+ """
88
+ if not REGEX_REPLACEMENTS:
89
+ return payload
90
+
91
+ modified_payload = payload.copy()
92
+ request_data = modified_payload.get("request", {})
93
+
94
+ # 处理contents中的文本
95
+ contents = request_data.get("contents", [])
96
+ if contents:
97
+ new_contents = []
98
+ for content in contents:
99
+ if isinstance(content, dict):
100
+ new_content = content.copy()
101
+ parts = new_content.get("parts", [])
102
+ if parts:
103
+ new_parts = []
104
+ for part in parts:
105
+ if isinstance(part, dict) and "text" in part:
106
+ new_part = part.copy()
107
+ new_part["text"] = apply_regex_replacements(part["text"])
108
+ new_parts.append(new_part)
109
+ else:
110
+ new_parts.append(part)
111
+ new_content["parts"] = new_parts
112
+ new_contents.append(new_content)
113
+ else:
114
+ new_contents.append(content)
115
+
116
+ request_data["contents"] = new_contents
117
+ modified_payload["request"] = request_data
118
+ log.debug("Applied regex replacements to request contents")
119
+
120
+ return modified_payload
121
+
122
+
123
+ def apply_anti_truncation(payload: Dict[str, Any]) -> Dict[str, Any]:
124
+ """
125
+ 对请求payload应用反截断处理和正则替换
126
+ 在systemInstruction中添加提醒,要求模型在结束时输出DONE_MARKER标记
127
+
128
+ Args:
129
+ payload: 原始请求payload
130
+
131
+ Returns:
132
+ 添加了反截断指令并应用了正则替换的payload
133
+ """
134
+ # 首先应用正则替换
135
+ modified_payload = apply_regex_replacements_to_payload(payload)
136
+ request_data = modified_payload.get("request", {})
137
+
138
+ # 获取或创建systemInstruction
139
+ system_instruction = request_data.get("systemInstruction", {})
140
+ if not system_instruction:
141
+ system_instruction = {"parts": []}
142
+ elif "parts" not in system_instruction:
143
+ system_instruction["parts"] = []
144
+
145
+ # 添加反截断指令
146
+ anti_truncation_instruction = {
147
+ "text": f"""严格执行以下输出结束规则:
148
+
149
+ 1. 当你完成完整回答时,必须在输出的最后单独一行输出:{DONE_MARKER}
150
+ 2. {DONE_MARKER} 标记表示你的回答已经完全结束,这是必需的结束标记
151
+ 3. 只有输出了 {DONE_MARKER} 标记,系统才认为你的回答是完整的
152
+ 4. 如果你的回答被截断,系统会要求你继续输出剩余内容
153
+ 5. 无论回答长短,都必须以 {DONE_MARKER} 标记结束
154
+
155
+ 示例格式:
156
+ ```
157
+ 你的回答内容...
158
+ 更多回答内容...
159
+ {DONE_MARKER}
160
+ ```
161
+
162
+ 注意:{DONE_MARKER} 必须单独占一行,前面不要有任何其他字符。
163
+
164
+ 这个规则对于确保输出完整性极其重要,请严格遵守。"""
165
+ }
166
+
167
+ # 检查是否已经包含反截断指令
168
+ has_done_instruction = any(
169
+ part.get("text", "").find(DONE_MARKER) != -1
170
+ for part in system_instruction["parts"]
171
+ if isinstance(part, dict)
172
+ )
173
+
174
+ if not has_done_instruction:
175
+ system_instruction["parts"].append(anti_truncation_instruction)
176
+ request_data["systemInstruction"] = system_instruction
177
+ modified_payload["request"] = request_data
178
+
179
+ log.debug("Applied anti-truncation instruction to request")
180
+
181
+ return modified_payload
182
+
183
+
184
+ class AntiTruncationStreamProcessor:
185
+ """反截断流式处理器"""
186
+
187
+ def __init__(
188
+ self,
189
+ original_request_func,
190
+ payload: Dict[str, Any],
191
+ max_attempts: int = 3,
192
+ enable_prefill_mode: bool = False,
193
+ ):
194
+ self.original_request_func = original_request_func
195
+ self.base_payload = payload.copy()
196
+ self.max_attempts = max_attempts
197
+ self.enable_prefill_mode = enable_prefill_mode
198
+ # 使用 StringIO 避免字符串拼接的内存问题
199
+ self.collected_content = io.StringIO()
200
+ self.current_attempt = 0
201
+
202
+ def _get_collected_text(self) -> str:
203
+ """获取收集的文本内容"""
204
+ return self.collected_content.getvalue()
205
+
206
+ def _append_content(self, content: str):
207
+ """追加内容到收集器"""
208
+ if content:
209
+ self.collected_content.write(content)
210
+
211
+ def _clear_content(self):
212
+ """清空收集的内容,释放内存"""
213
+ self.collected_content.close()
214
+ self.collected_content = io.StringIO()
215
+
216
+ async def process_stream(self) -> AsyncGenerator[bytes, None]:
217
+ """处理流式响应,检测并处理截断"""
218
+
219
+ while self.current_attempt < self.max_attempts:
220
+ self.current_attempt += 1
221
+
222
+ # 构建当前请求payload
223
+ current_payload = self._build_current_payload()
224
+
225
+ log.debug(f"Anti-truncation attempt {self.current_attempt}/{self.max_attempts}")
226
+
227
+ # 发送请求
228
+ try:
229
+ response = await self.original_request_func(current_payload)
230
+
231
+ if not isinstance(response, StreamingResponse):
232
+ # 非流式响应,直接处理
233
+ yield await self._handle_non_streaming_response(response)
234
+ return
235
+
236
+ # 处理流式响应(按行处理)
237
+ chunk_buffer = io.StringIO() # 使用 StringIO 缓存当前轮次的chunk
238
+ found_done_marker = False
239
+
240
+ async for line in response.body_iterator:
241
+ if not line:
242
+ yield line
243
+ continue
244
+
245
+ # 处理上游生成器 yield 出 Response 对象的情况(错误响应)
246
+ from fastapi import Response as FastAPIResponse
247
+ if isinstance(line, FastAPIResponse):
248
+ log.error(f"Anti-truncation: Received Response object from stream (status={line.status_code}), treating as error")
249
+ error_chunk = {
250
+ "error": {
251
+ "message": line.body.decode('utf-8', errors='ignore') if hasattr(line, 'body') and line.body else "Upstream error",
252
+ "type": "api_error",
253
+ "code": line.status_code,
254
+ }
255
+ }
256
+ yield f"data: {json.dumps(error_chunk)}\n\n".encode()
257
+ yield b"data: [DONE]\n\n"
258
+ return
259
+
260
+ # 处理 bytes 类型的流式数据
261
+ if isinstance(line, bytes):
262
+ # 解码 bytes 为字符串
263
+ line_str = line.decode('utf-8', errors='ignore').strip()
264
+ else:
265
+ line_str = str(line).strip()
266
+
267
+ # 跳过空行
268
+ if not line_str:
269
+ yield line
270
+ continue
271
+
272
+ # 处理 SSE 格式的数据行
273
+ if line_str.startswith("data: "):
274
+ payload_str = line_str[6:] # 去掉 "data: " 前缀
275
+
276
+ # 检查是否是 [DONE] 标记
277
+ if payload_str.strip() == "[DONE]":
278
+ if found_done_marker:
279
+ log.info("Anti-truncation: Found [done] marker, output complete")
280
+ yield line
281
+ # 清理内存
282
+ chunk_buffer.close()
283
+ self._clear_content()
284
+ return
285
+ else:
286
+ log.warning("Anti-truncation: Stream ended without [done] marker")
287
+ # 不发送[DONE],准备继续
288
+ break
289
+
290
+ # 尝试解析 JSON 数据
291
+ try:
292
+ data = json.loads(payload_str)
293
+ content = self._extract_content_from_chunk(data)
294
+
295
+ log.debug(f"Anti-truncation: Extracted content: {repr(content[:100] if content else '')}")
296
+
297
+ if content:
298
+ chunk_buffer.write(content)
299
+
300
+ # 检查是否包含done标记
301
+ has_marker = self._check_done_marker_in_chunk_content(content)
302
+ log.debug(f"Anti-truncation: Check done marker result: {has_marker}, DONE_MARKER='{DONE_MARKER}'")
303
+ if has_marker:
304
+ found_done_marker = True
305
+ log.debug(f"Anti-truncation: Found [done] marker in chunk, content: {content[:200]}")
306
+
307
+ # 清理行中的[done]标记后再发送
308
+ cleaned_line = self._remove_done_marker_from_line(line, line_str, data)
309
+ yield cleaned_line
310
+
311
+ except (json.JSONDecodeError, ValueError):
312
+ # 无法解析的行,直接传递
313
+ yield line
314
+ continue
315
+ else:
316
+ # 非 data: 开头的行,直接传递
317
+ yield line
318
+
319
+ # 更新收集的内容 - 使用 StringIO 高效处理
320
+ chunk_text = chunk_buffer.getvalue()
321
+ if chunk_text:
322
+ self._append_content(chunk_text)
323
+ chunk_buffer.close()
324
+
325
+ log.debug(f"Anti-truncation: After processing stream, found_done_marker={found_done_marker}")
326
+
327
+ # 如果找到了done标记,结束
328
+ if found_done_marker:
329
+ # 立即清理内容释放内存
330
+ self._clear_content()
331
+ yield b"data: [DONE]\n\n"
332
+ return
333
+
334
+ # 只有在单个chunk中没有找到done标记时,才检查累积内容(防止done标记跨chunk出现)
335
+ if not found_done_marker:
336
+ accumulated_text = self._get_collected_text()
337
+ if self._check_done_marker_in_text(accumulated_text):
338
+ log.info("Anti-truncation: Found [done] marker in accumulated content")
339
+ # 立即清理内容释放内存
340
+ self._clear_content()
341
+ yield b"data: [DONE]\n\n"
342
+ return
343
+
344
+ # 如果没找到done标记且不是最后一次尝试,准备续传
345
+ if self.current_attempt < self.max_attempts:
346
+ accumulated_text = self._get_collected_text()
347
+ total_length = len(accumulated_text)
348
+ log.info(
349
+ f"Anti-truncation: No [done] marker found in output (length: {total_length}), preparing continuation (attempt {self.current_attempt + 1})"
350
+ )
351
+ if total_length > 100:
352
+ log.debug(
353
+ f"Anti-truncation: Current collected content ends with: ...{accumulated_text[-100:]}"
354
+ )
355
+ # 在下一次循环中会继续
356
+ continue
357
+ else:
358
+ # 最后一次尝试,直接结束
359
+ log.warning("Anti-truncation: Max attempts reached, ending stream")
360
+ # 立即清理内容释放内存
361
+ self._clear_content()
362
+ yield b"data: [DONE]\n\n"
363
+ return
364
+
365
+ except Exception as e:
366
+ log.error(f"Anti-truncation error in attempt {self.current_attempt}: {str(e)}")
367
+ if self.current_attempt >= self.max_attempts:
368
+ # 发送错误chunk
369
+ error_chunk = {
370
+ "error": {
371
+ "message": f"Anti-truncation failed: {str(e)}",
372
+ "type": "api_error",
373
+ "code": 500,
374
+ }
375
+ }
376
+ yield f"data: {json.dumps(error_chunk)}\n\n".encode()
377
+ yield b"data: [DONE]\n\n"
378
+ return
379
+ # 否则继续下一次尝试
380
+
381
+ # 如果所有尝试都失败了
382
+ log.error("Anti-truncation: All attempts failed")
383
+ # 清理内存
384
+ self._clear_content()
385
+ yield b"data: [DONE]\n\n"
386
+
387
+ def _build_current_payload(self) -> Dict[str, Any]:
388
+ """构建当前请求的payload"""
389
+ if self.current_attempt == 1:
390
+ # 第一次请求,使用原始payload(已经包含反截断指令)
391
+ return self.base_payload
392
+
393
+ # 后续请求,添加续传指令
394
+ continuation_payload = self.base_payload.copy()
395
+ request_data = continuation_payload.get("request", {})
396
+
397
+ # 获取原始对话内容
398
+ contents = request_data.get("contents", [])
399
+ new_contents = contents.copy()
400
+
401
+ # 如果有收集到的内容,添加到对话中
402
+ accumulated_text = self._get_collected_text()
403
+ if accumulated_text:
404
+ new_contents.append({"role": "model", "parts": [{"text": accumulated_text}]})
405
+
406
+ # 预填充模式:直接用拼接内容作为末尾 model 预填充,不再增加 user 续写指令
407
+ if self.enable_prefill_mode:
408
+ log.debug("Anti-truncation: Using prefill continuation mode (no user continuation prompt)")
409
+ request_data["contents"] = new_contents
410
+ continuation_payload["request"] = request_data
411
+ return continuation_payload
412
+
413
+ # 构建具体的续写指令,包含前面的内容摘要
414
+ content_summary = ""
415
+ if accumulated_text:
416
+ if len(accumulated_text) > 200:
417
+ content_summary = f'\n\n前面你已经输出了约 {len(accumulated_text)} 个字符的内容,结尾是:\n"...{accumulated_text[-100:]}"'
418
+ else:
419
+ content_summary = f'\n\n前面你已经输出的内容是:\n"{accumulated_text}"'
420
+
421
+ detailed_continuation_prompt = f"""{CONTINUATION_PROMPT}{content_summary}"""
422
+
423
+ # 添加继续指令
424
+ continuation_message = {"role": "user", "parts": [{"text": detailed_continuation_prompt}]}
425
+ new_contents.append(continuation_message)
426
+
427
+ request_data["contents"] = new_contents
428
+ continuation_payload["request"] = request_data
429
+
430
+ return continuation_payload
431
+
432
+ def _extract_content_from_chunk(self, data: Dict[str, Any]) -> str:
433
+ """从chunk数据中提取文本内容"""
434
+ content = ""
435
+
436
+ # 先尝试解包 response 字段(Gemini API 格式)
437
+ if "response" in data:
438
+ data = data["response"]
439
+
440
+ # 处理 Gemini 格式
441
+ if "candidates" in data:
442
+ for candidate in data["candidates"]:
443
+ if "content" in candidate:
444
+ parts = candidate["content"].get("parts", [])
445
+ for part in parts:
446
+ if "text" in part:
447
+ content += part["text"]
448
+
449
+ # 处理 OpenAI 流式格式(choices/delta)
450
+ elif "choices" in data:
451
+ for choice in data["choices"]:
452
+ if "delta" in choice and "content" in choice["delta"]:
453
+ delta_content = choice["delta"]["content"]
454
+ if delta_content:
455
+ content += delta_content
456
+
457
+ return content
458
+
459
+ async def _handle_non_streaming_response(self, response) -> bytes:
460
+ """处理非流式响应 - 使用循环代替递归避免栈溢出"""
461
+ # 使用循环代替递归
462
+ while True:
463
+ try:
464
+ # 特殊处理:如果返回的是StreamingResponse,需要读取其body_iterator
465
+ if isinstance(response, StreamingResponse):
466
+ log.error("Anti-truncation: Received StreamingResponse in non-streaming handler - this should not happen")
467
+ # 尝试读取流式响应的内容
468
+ chunks = []
469
+ async for chunk in response.body_iterator:
470
+ chunks.append(chunk)
471
+ content = b"".join(chunks).decode() if chunks else ""
472
+ # 提取响应内容
473
+ elif hasattr(response, "body"):
474
+ content = (
475
+ response.body.decode() if isinstance(response.body, bytes) else response.body
476
+ )
477
+ elif hasattr(response, "content"):
478
+ content = (
479
+ response.content.decode()
480
+ if isinstance(response.content, bytes)
481
+ else response.content
482
+ )
483
+ else:
484
+ log.error(f"Anti-truncation: Unknown response type: {type(response)}")
485
+ content = str(response)
486
+
487
+ # 验证内容不为空
488
+ if not content or not content.strip():
489
+ log.error("Anti-truncation: Received empty response content")
490
+ return json.dumps(
491
+ {
492
+ "error": {
493
+ "message": "Empty response from server",
494
+ "type": "api_error",
495
+ "code": 500,
496
+ }
497
+ }
498
+ ).encode()
499
+
500
+ # 尝试解析 JSON
501
+ try:
502
+ response_data = json.loads(content)
503
+ except json.JSONDecodeError as json_err:
504
+ log.error(f"Anti-truncation: Failed to parse JSON response: {json_err}, content: {content[:200]}")
505
+ # 如果不是 JSON,直接返回原始内容
506
+ return content.encode() if isinstance(content, str) else content
507
+
508
+ # 检查是否包含done标记
509
+ text_content = self._extract_content_from_response(response_data)
510
+ has_done_marker = self._check_done_marker_in_text(text_content)
511
+
512
+ if has_done_marker or self.current_attempt >= self.max_attempts:
513
+ # 找到done标记或达到最大尝试次数,返回结果
514
+ return content.encode() if isinstance(content, str) else content
515
+
516
+ # 需要继续,收集内容并构建下一个请求
517
+ if text_content:
518
+ self._append_content(text_content)
519
+
520
+ log.info("Anti-truncation: Non-streaming response needs continuation")
521
+
522
+ # 增加尝试次数
523
+ self.current_attempt += 1
524
+
525
+ # 构建续传payload并发送下一个请求
526
+ next_payload = self._build_current_payload()
527
+ response = await self.original_request_func(next_payload)
528
+
529
+ # 继续循环处理下一个响应
530
+
531
+ except Exception as e:
532
+ log.error(f"Anti-truncation non-streaming error: {str(e)}")
533
+ return json.dumps(
534
+ {
535
+ "error": {
536
+ "message": f"Anti-truncation failed: {str(e)}",
537
+ "type": "api_error",
538
+ "code": 500,
539
+ }
540
+ }
541
+ ).encode()
542
+
543
+ def _check_done_marker_in_text(self, text: str) -> bool:
544
+ """检测文本中是否包含DONE_MARKER(只检测指定标记)"""
545
+ if not text:
546
+ return False
547
+
548
+ # 只要文本中出现DONE_MARKER即可
549
+ return DONE_MARKER in text
550
+
551
+ def _check_done_marker_in_chunk_content(self, content: str) -> bool:
552
+ """检查单个chunk内容中是否包含done标记"""
553
+ return self._check_done_marker_in_text(content)
554
+
555
+ def _extract_content_from_response(self, data: Dict[str, Any]) -> str:
556
+ """从响应数据中提取文本内容"""
557
+ content = ""
558
+
559
+ # 先尝试解包 response 字段(Gemini API 格式)
560
+ if "response" in data:
561
+ data = data["response"]
562
+
563
+ # 处理Gemini格式
564
+ if "candidates" in data:
565
+ for candidate in data["candidates"]:
566
+ if "content" in candidate:
567
+ parts = candidate["content"].get("parts", [])
568
+ for part in parts:
569
+ if "text" in part:
570
+ content += part["text"]
571
+
572
+ # 处理OpenAI格式
573
+ elif "choices" in data:
574
+ for choice in data["choices"]:
575
+ if "message" in choice and "content" in choice["message"]:
576
+ content += choice["message"]["content"]
577
+
578
+ return content
579
+
580
+ def _remove_done_marker_from_line(self, line: bytes, line_str: str, data: Dict[str, Any]) -> bytes:
581
+ """从行中移除[done]标记"""
582
+ try:
583
+ # 首先检查是否真的包含[done]标记
584
+ if "[done]" not in line_str.lower():
585
+ return line # 没有[done]标记,直接返回原始行
586
+
587
+ log.info(f"Anti-truncation: Attempting to remove [done] marker from line")
588
+ log.debug(f"Anti-truncation: Original line (first 200 chars): {line_str[:200]}")
589
+
590
+ # 编译正则表达式,匹配[done]标记(忽略大小写,包括可能的空白字符)
591
+ done_pattern = re.compile(r"\s*\[done\]\s*", re.IGNORECASE)
592
+
593
+ # 检查是否有 response 包裹层
594
+ has_response_wrapper = "response" in data
595
+ log.debug(f"Anti-truncation: has_response_wrapper={has_response_wrapper}, data keys={list(data.keys())}")
596
+ if has_response_wrapper:
597
+ # 需要保留外层的 response 字段
598
+ inner_data = data["response"]
599
+ else:
600
+ inner_data = data
601
+
602
+ log.debug(f"Anti-truncation: inner_data keys={list(inner_data.keys())}")
603
+
604
+ log.debug(f"Anti-truncation: inner_data keys={list(inner_data.keys())}")
605
+
606
+ # 处理Gemini格式
607
+ if "candidates" in inner_data:
608
+ log.info(f"Anti-truncation: Processing Gemini format to remove [done] marker")
609
+ modified_inner = inner_data.copy()
610
+ modified_inner["candidates"] = []
611
+
612
+ for i, candidate in enumerate(inner_data["candidates"]):
613
+ modified_candidate = candidate.copy()
614
+ # 只在最后一个candidate中清理[done]标记
615
+ is_last_candidate = i == len(inner_data["candidates"]) - 1
616
+
617
+ if "content" in candidate:
618
+ modified_content = candidate["content"].copy()
619
+ if "parts" in modified_content:
620
+ modified_parts = []
621
+ for part in modified_content["parts"]:
622
+ if "text" in part and isinstance(part["text"], str):
623
+ modified_part = part.copy()
624
+ original_text = part["text"]
625
+ # 只在最后一个candidate中清理[done]标记
626
+ if is_last_candidate:
627
+ modified_part["text"] = done_pattern.sub("", part["text"])
628
+ if "[done]" in original_text.lower():
629
+ log.debug(f"Anti-truncation: Removed [done] from text: '{original_text[:100]}' -> '{modified_part['text'][:100]}'")
630
+ modified_parts.append(modified_part)
631
+ else:
632
+ modified_parts.append(part)
633
+ modified_content["parts"] = modified_parts
634
+ modified_candidate["content"] = modified_content
635
+ modified_inner["candidates"].append(modified_candidate)
636
+
637
+ # 如果有 response 包裹层,需要重新包装
638
+ if has_response_wrapper:
639
+ modified_data = data.copy()
640
+ modified_data["response"] = modified_inner
641
+ else:
642
+ modified_data = modified_inner
643
+
644
+ # 重新编码为行格式 - SSE格式需要两个换行符
645
+ json_str = json.dumps(modified_data, separators=(",", ":"), ensure_ascii=False)
646
+ result = f"data: {json_str}\n\n".encode("utf-8")
647
+ log.debug(f"Anti-truncation: Modified line (first 200 chars): {result.decode('utf-8', errors='ignore')[:200]}")
648
+ return result
649
+
650
+ # 处理OpenAI格式
651
+ elif "choices" in inner_data:
652
+ modified_inner = inner_data.copy()
653
+ modified_inner["choices"] = []
654
+
655
+ for choice in inner_data["choices"]:
656
+ modified_choice = choice.copy()
657
+ if "delta" in choice and "content" in choice["delta"]:
658
+ modified_delta = choice["delta"].copy()
659
+ modified_delta["content"] = done_pattern.sub("", choice["delta"]["content"])
660
+ modified_choice["delta"] = modified_delta
661
+ elif "message" in choice and "content" in choice["message"]:
662
+ modified_message = choice["message"].copy()
663
+ modified_message["content"] = done_pattern.sub("", choice["message"]["content"])
664
+ modified_choice["message"] = modified_message
665
+ modified_inner["choices"].append(modified_choice)
666
+
667
+ # 如果有 response 包裹层,需要重新包装
668
+ if has_response_wrapper:
669
+ modified_data = data.copy()
670
+ modified_data["response"] = modified_inner
671
+ else:
672
+ modified_data = modified_inner
673
+
674
+ # 重新编码为行格式 - SSE格式需要两个换行符
675
+ json_str = json.dumps(modified_data, separators=(",", ":"), ensure_ascii=False)
676
+ return f"data: {json_str}\n\n".encode("utf-8")
677
+
678
+ # 如果没有找到支持的格式,返回原始行
679
+ return line
680
+
681
+ except Exception as e:
682
+ log.warning(f"Failed to remove [done] marker from line: {str(e)}")
683
+ return line
684
+
685
+
686
+ async def apply_anti_truncation_to_stream(
687
+ request_func,
688
+ payload: Dict[str, Any],
689
+ max_attempts: int = 3,
690
+ enable_prefill_mode: bool = False,
691
+ ) -> StreamingResponse:
692
+ """
693
+ 对流式请求应用反截断处理
694
+
695
+ Args:
696
+ request_func: 原始请求函数
697
+ payload: 请求payload
698
+ max_attempts: 最大续传尝试次数
699
+ enable_prefill_mode: 是否启用预填充模式。启用后续传请求不再添加 user 续写指令,
700
+ 而是将已收集内容作为末尾 model 内容进行预填充
701
+
702
+ Returns:
703
+ 处理后的StreamingResponse
704
+ """
705
+
706
+ # 首先对payload应用反截断指令
707
+ anti_truncation_payload = apply_anti_truncation(payload)
708
+
709
+ # 创建反截断处理器
710
+ processor = AntiTruncationStreamProcessor(
711
+ lambda p: request_func(p),
712
+ anti_truncation_payload,
713
+ max_attempts,
714
+ enable_prefill_mode,
715
+ )
716
+
717
+ # 返回包装后的流式响应
718
+ return StreamingResponse(processor.process_stream(), media_type="text/event-stream")
719
+
720
+
721
+ def is_anti_truncation_enabled(request_data: Dict[str, Any]) -> bool:
722
+ """
723
+ 检查请求是否启用了反截断功能
724
+
725
+ Args:
726
+ request_data: 请求数据
727
+
728
+ Returns:
729
+ 是否启用反截断
730
+ """
731
+ return request_data.get("enable_anti_truncation", False)
src/converter/fake_stream.py ADDED
@@ -0,0 +1,537 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Any, Dict, List, Tuple
2
+ import json
3
+ from src.converter.utils import extract_content_and_reasoning
4
+ from log import log
5
+ from src.converter.openai2gemini import _convert_usage_metadata
6
+
7
+ def safe_get_nested(obj: Any, *keys: str, default: Any = None) -> Any:
8
+ """安全获取嵌套字典值
9
+
10
+ Args:
11
+ obj: 字典对象
12
+ *keys: 嵌套键路径
13
+ default: 默认值
14
+
15
+ Returns:
16
+ 获取到的值或默认值
17
+ """
18
+ for key in keys:
19
+ if not isinstance(obj, dict):
20
+ return default
21
+ obj = obj.get(key, default)
22
+ if obj is default:
23
+ return default
24
+ return obj
25
+
26
+ def parse_response_for_fake_stream(response_data: Dict[str, Any]) -> tuple:
27
+ """从完整响应中提取内容和推理内容(用于假流式)
28
+
29
+ Args:
30
+ response_data: Gemini API 响应数据
31
+
32
+ Returns:
33
+ (content, reasoning_content, finish_reason, images): 内容、推理内容、结束原因和图片数据的元组
34
+ """
35
+ import json
36
+
37
+ # 处理GeminiCLI的response包装格式
38
+ if "response" in response_data and "candidates" not in response_data:
39
+ log.debug(f"[FAKE_STREAM] Unwrapping response field")
40
+ response_data = response_data["response"]
41
+
42
+ candidates = response_data.get("candidates", [])
43
+ log.debug(f"[FAKE_STREAM] Found {len(candidates)} candidates")
44
+ if not candidates:
45
+ return "", "", "STOP", []
46
+
47
+ candidate = candidates[0]
48
+ finish_reason = candidate.get("finishReason", "STOP")
49
+ parts = safe_get_nested(candidate, "content", "parts", default=[])
50
+ log.debug(f"[FAKE_STREAM] Extracted {len(parts)} parts: {json.dumps(parts, ensure_ascii=False)}")
51
+ content, reasoning_content, images = extract_content_and_reasoning(parts)
52
+ log.debug(f"[FAKE_STREAM] Content length: {len(content)}, Reasoning length: {len(reasoning_content)}, Images count: {len(images)}")
53
+
54
+ return content, reasoning_content, finish_reason, images
55
+
56
+ def extract_fake_stream_content(response: Any) -> Tuple[str, str, Dict[str, int]]:
57
+ """
58
+ 从 Gemini 非流式响应中提取内容,用于假流式处理
59
+
60
+ Args:
61
+ response: Gemini API 响应对象
62
+
63
+ Returns:
64
+ (content, reasoning_content, usage) 元组
65
+ """
66
+ from src.converter.utils import extract_content_and_reasoning
67
+
68
+ # 解析响应体
69
+ if hasattr(response, "body"):
70
+ body_str = (
71
+ response.body.decode()
72
+ if isinstance(response.body, bytes)
73
+ else str(response.body)
74
+ )
75
+ elif hasattr(response, "content"):
76
+ body_str = (
77
+ response.content.decode()
78
+ if isinstance(response.content, bytes)
79
+ else str(response.content)
80
+ )
81
+ else:
82
+ body_str = str(response)
83
+
84
+ try:
85
+ response_data = json.loads(body_str)
86
+
87
+ # GeminiCLI 返回的格式是 {"response": {...}, "traceId": "..."}
88
+ # 需要先提取 response 字段
89
+ if "response" in response_data:
90
+ gemini_response = response_data["response"]
91
+ else:
92
+ gemini_response = response_data
93
+
94
+ # 从Gemini响应中提取内容,使用思维链分离逻辑
95
+ content = ""
96
+ reasoning_content = ""
97
+ images = []
98
+ if "candidates" in gemini_response and gemini_response["candidates"]:
99
+ # Gemini格式响应 - 使用思维链分离
100
+ candidate = gemini_response["candidates"][0]
101
+ if "content" in candidate and "parts" in candidate["content"]:
102
+ parts = candidate["content"]["parts"]
103
+ content, reasoning_content, images = extract_content_and_reasoning(parts)
104
+ elif "choices" in gemini_response and gemini_response["choices"]:
105
+ # OpenAI格式响应
106
+ content = gemini_response["choices"][0].get("message", {}).get("content", "")
107
+
108
+ # 如果没有正常内容但有思维内容,给出警告
109
+ if not content and reasoning_content:
110
+ log.warning("Fake stream response contains only thinking content")
111
+ content = "[模型正在思考中,请稍后再试或重新提问]"
112
+
113
+ # 如果完全没有内容,提供默认回复
114
+ if not content:
115
+ log.warning(f"No content found in response: {gemini_response}")
116
+ content = "[响应为空,请重新尝试]"
117
+
118
+ # 转换usageMetadata为OpenAI格式
119
+ usage = _convert_usage_metadata(gemini_response.get("usageMetadata"))
120
+
121
+ return content, reasoning_content, usage
122
+
123
+ except json.JSONDecodeError:
124
+ # 如果不是JSON,直接返回原始文本
125
+ return body_str, "", None
126
+
127
+ def _build_candidate(parts: List[Dict[str, Any]], finish_reason: str = "STOP") -> Dict[str, Any]:
128
+ """构建标准候选响应结构
129
+
130
+ Args:
131
+ parts: parts 列表
132
+ finish_reason: 结束原因
133
+
134
+ Returns:
135
+ 候选响应字典
136
+ """
137
+ return {
138
+ "candidates": [{
139
+ "content": {"parts": parts, "role": "model"},
140
+ "finishReason": finish_reason,
141
+ "index": 0,
142
+ }]
143
+ }
144
+
145
+ def create_openai_heartbeat_chunk() -> Dict[str, Any]:
146
+ """
147
+ 创建 OpenAI 格式的心跳块(用于假流式)
148
+
149
+ Returns:
150
+ 心跳响应块字典
151
+ """
152
+ return {
153
+ "choices": [
154
+ {
155
+ "index": 0,
156
+ "delta": {"role": "assistant", "content": ""},
157
+ "finish_reason": None,
158
+ }
159
+ ]
160
+ }
161
+
162
+ def build_gemini_fake_stream_chunks(content: str, reasoning_content: str, finish_reason: str, images: List[Dict[str, Any]] = None, chunk_size: int = 50) -> List[Dict[str, Any]]:
163
+ """构建假流式响应的数据块
164
+
165
+ Args:
166
+ content: 主要内容
167
+ reasoning_content: 推理内容
168
+ finish_reason: 结束原因
169
+ images: 图片数据列表(可选)
170
+ chunk_size: 每个chunk的字符数(默认50)
171
+
172
+ Returns:
173
+ 响应数据块列表
174
+ """
175
+ if images is None:
176
+ images = []
177
+
178
+ log.debug(f"[build_gemini_fake_stream_chunks] Input - content: {repr(content)}, reasoning: {repr(reasoning_content)}, finish_reason: {finish_reason}, images count: {len(images)}")
179
+ chunks = []
180
+
181
+ # 如果没有正常内容但有思维内容,提供默认回复
182
+ if not content:
183
+ default_text = "[模型正在思考中,请稍后再试或重新提问]" if reasoning_content else "[响应为空,请重新尝试]"
184
+ return [_build_candidate([{"text": default_text}], finish_reason)]
185
+
186
+ # 分块发送主要内容
187
+ first_chunk = True
188
+ for i in range(0, len(content), chunk_size):
189
+ chunk_text = content[i:i + chunk_size]
190
+ is_last_chunk = (i + chunk_size >= len(content)) and not reasoning_content
191
+ chunk_finish_reason = finish_reason if is_last_chunk else None
192
+
193
+ # 如果是第一个chunk且有图片,将图片包含在parts中
194
+ parts = []
195
+ if first_chunk and images:
196
+ # 在Gemini格式中,需要将image_url格式转换为inlineData格式
197
+ for img in images:
198
+ if img.get("type") == "image_url":
199
+ url = img.get("image_url", {}).get("url", "")
200
+ # 解析 data URL: data:{mime_type};base64,{data}
201
+ if url.startswith("data:"):
202
+ parts_of_url = url.split(";base64,")
203
+ if len(parts_of_url) == 2:
204
+ mime_type = parts_of_url[0].replace("data:", "")
205
+ base64_data = parts_of_url[1]
206
+ parts.append({
207
+ "inlineData": {
208
+ "mimeType": mime_type,
209
+ "data": base64_data
210
+ }
211
+ })
212
+ first_chunk = False
213
+
214
+ parts.append({"text": chunk_text})
215
+ chunk_data = _build_candidate(parts, chunk_finish_reason)
216
+ log.debug(f"[build_gemini_fake_stream_chunks] Generated chunk: {chunk_data}")
217
+ chunks.append(chunk_data)
218
+
219
+ # 如果有推理内容,分块发送
220
+ if reasoning_content:
221
+ for i in range(0, len(reasoning_content), chunk_size):
222
+ chunk_text = reasoning_content[i:i + chunk_size]
223
+ is_last_chunk = i + chunk_size >= len(reasoning_content)
224
+ chunk_finish_reason = finish_reason if is_last_chunk else None
225
+ chunks.append(_build_candidate([{"text": chunk_text, "thought": True}], chunk_finish_reason))
226
+
227
+ log.debug(f"[build_gemini_fake_stream_chunks] Total chunks generated: {len(chunks)}")
228
+ return chunks
229
+
230
+
231
+ def create_gemini_heartbeat_chunk() -> Dict[str, Any]:
232
+ """创建 Gemini 格式的心跳数据块
233
+
234
+ Returns:
235
+ 心跳数据块
236
+ """
237
+ chunk = _build_candidate([{"text": ""}])
238
+ chunk["candidates"][0]["finishReason"] = None
239
+ return chunk
240
+
241
+
242
+ def build_openai_fake_stream_chunks(content: str, reasoning_content: str, finish_reason: str, model: str, images: List[Dict[str, Any]] = None, chunk_size: int = 50) -> List[Dict[str, Any]]:
243
+ """构建 OpenAI 格式的假流式响应数据块
244
+
245
+ Args:
246
+ content: 主要内容
247
+ reasoning_content: 推理内容
248
+ finish_reason: 结束原因(如 "STOP", "MAX_TOKENS")
249
+ model: 模型名称
250
+ images: 图片数据列表(可选)
251
+ chunk_size: 每个chunk的字符数(默认50)
252
+
253
+ Returns:
254
+ OpenAI 格式的响应数据块列表
255
+ """
256
+ import time
257
+ import uuid
258
+
259
+ if images is None:
260
+ images = []
261
+
262
+ log.debug(f"[build_openai_fake_stream_chunks] Input - content: {repr(content)}, reasoning: {repr(reasoning_content)}, finish_reason: {finish_reason}, images count: {len(images)}")
263
+ chunks = []
264
+ response_id = f"chatcmpl-{uuid.uuid4().hex[:24]}"
265
+ created = int(time.time())
266
+
267
+ # 映射 Gemini finish_reason 到 OpenAI 格式
268
+ openai_finish_reason = None
269
+ if finish_reason == "STOP":
270
+ openai_finish_reason = "stop"
271
+ elif finish_reason == "MAX_TOKENS":
272
+ openai_finish_reason = "length"
273
+ elif finish_reason in ["SAFETY", "RECITATION"]:
274
+ openai_finish_reason = "content_filter"
275
+
276
+ # 如果没有正常内容但有思维内容,提供默认回复
277
+ if not content:
278
+ default_text = "[模型正在思考中,请稍后再试或重新提问]" if reasoning_content else "[响应为空,请重新尝试]"
279
+ return [{
280
+ "id": response_id,
281
+ "object": "chat.completion.chunk",
282
+ "created": created,
283
+ "model": model,
284
+ "choices": [{
285
+ "index": 0,
286
+ "delta": {"content": default_text},
287
+ "finish_reason": openai_finish_reason,
288
+ }]
289
+ }]
290
+
291
+ # 分块发送主要内容
292
+ first_chunk = True
293
+ for i in range(0, len(content), chunk_size):
294
+ chunk_text = content[i:i + chunk_size]
295
+ is_last_chunk = (i + chunk_size >= len(content)) and not reasoning_content
296
+ chunk_finish = openai_finish_reason if is_last_chunk else None
297
+
298
+ delta_content = {}
299
+
300
+ # 如果是第一个chunk且有图片,构建包含图片的content数组
301
+ if first_chunk and images:
302
+ delta_content["content"] = images + [{"type": "text", "text": chunk_text}]
303
+ first_chunk = False
304
+ else:
305
+ delta_content["content"] = chunk_text
306
+
307
+ chunk_data = {
308
+ "id": response_id,
309
+ "object": "chat.completion.chunk",
310
+ "created": created,
311
+ "model": model,
312
+ "choices": [{
313
+ "index": 0,
314
+ "delta": delta_content,
315
+ "finish_reason": chunk_finish,
316
+ }]
317
+ }
318
+ log.debug(f"[build_openai_fake_stream_chunks] Generated chunk: {chunk_data}")
319
+ chunks.append(chunk_data)
320
+
321
+ # 如果有推理内容,分块发送(使用 reasoning_content 字段)
322
+ if reasoning_content:
323
+ for i in range(0, len(reasoning_content), chunk_size):
324
+ chunk_text = reasoning_content[i:i + chunk_size]
325
+ is_last_chunk = i + chunk_size >= len(reasoning_content)
326
+ chunk_finish = openai_finish_reason if is_last_chunk else None
327
+
328
+ chunks.append({
329
+ "id": response_id,
330
+ "object": "chat.completion.chunk",
331
+ "created": created,
332
+ "model": model,
333
+ "choices": [{
334
+ "index": 0,
335
+ "delta": {"reasoning_content": chunk_text},
336
+ "finish_reason": chunk_finish,
337
+ }]
338
+ })
339
+
340
+ log.debug(f"[build_openai_fake_stream_chunks] Total chunks generated: {len(chunks)}")
341
+ return chunks
342
+
343
+
344
+ def create_anthropic_heartbeat_chunk() -> Dict[str, Any]:
345
+ """
346
+ 创建 Anthropic 格式的心跳块(用于假流式)
347
+
348
+ Returns:
349
+ 心跳响应块字典
350
+ """
351
+ return {
352
+ "type": "ping"
353
+ }
354
+
355
+
356
+ def build_anthropic_fake_stream_chunks(content: str, reasoning_content: str, finish_reason: str, model: str, images: List[Dict[str, Any]] = None, chunk_size: int = 50) -> List[Dict[str, Any]]:
357
+ """构建 Anthropic 格式的假流式响应数据块
358
+
359
+ Args:
360
+ content: 主要内容
361
+ reasoning_content: 推理内容(thinking content)
362
+ finish_reason: 结束原因(如 "STOP", "MAX_TOKENS")
363
+ model: 模型名称
364
+ images: 图片数据列表(可选)
365
+ chunk_size: 每个chunk的字符数(默认50)
366
+
367
+ Returns:
368
+ Anthropic SSE 格式的响应数据块列表
369
+ """
370
+ import uuid
371
+
372
+ if images is None:
373
+ images = []
374
+
375
+ log.debug(f"[build_anthropic_fake_stream_chunks] Input - content: {repr(content)}, reasoning: {repr(reasoning_content)}, finish_reason: {finish_reason}, images count: {len(images)}")
376
+ chunks = []
377
+ message_id = f"msg_{uuid.uuid4().hex}"
378
+
379
+ # 映射 Gemini finish_reason 到 Anthropic 格式
380
+ anthropic_stop_reason = "end_turn"
381
+ if finish_reason == "MAX_TOKENS":
382
+ anthropic_stop_reason = "max_tokens"
383
+ elif finish_reason in ["SAFETY", "RECITATION"]:
384
+ anthropic_stop_reason = "end_turn"
385
+
386
+ # 1. 发送 message_start 事件
387
+ chunks.append({
388
+ "type": "message_start",
389
+ "message": {
390
+ "id": message_id,
391
+ "type": "message",
392
+ "role": "assistant",
393
+ "model": model,
394
+ "content": [],
395
+ "stop_reason": None,
396
+ "stop_sequence": None,
397
+ "usage": {"input_tokens": 0, "output_tokens": 0}
398
+ }
399
+ })
400
+
401
+ # 如果没有正常内容但有思维内容,提供默认回复
402
+ if not content:
403
+ default_text = "[模型正在思考中,请稍后再试或重新提问]" if reasoning_content else "[响应为空,请重新尝试]"
404
+
405
+ # content_block_start
406
+ chunks.append({
407
+ "type": "content_block_start",
408
+ "index": 0,
409
+ "content_block": {"type": "text", "text": ""}
410
+ })
411
+
412
+ # content_block_delta
413
+ chunks.append({
414
+ "type": "content_block_delta",
415
+ "index": 0,
416
+ "delta": {"type": "text_delta", "text": default_text}
417
+ })
418
+
419
+ # content_block_stop
420
+ chunks.append({
421
+ "type": "content_block_stop",
422
+ "index": 0
423
+ })
424
+
425
+ # message_delta
426
+ chunks.append({
427
+ "type": "message_delta",
428
+ "delta": {"stop_reason": anthropic_stop_reason, "stop_sequence": None},
429
+ "usage": {"output_tokens": 0}
430
+ })
431
+
432
+ # message_stop
433
+ chunks.append({
434
+ "type": "message_stop"
435
+ })
436
+
437
+ return chunks
438
+
439
+ block_index = 0
440
+
441
+ # 2. 如果有推理内容,先发送 thinking 块
442
+ if reasoning_content:
443
+ # thinking content_block_start
444
+ chunks.append({
445
+ "type": "content_block_start",
446
+ "index": block_index,
447
+ "content_block": {"type": "thinking", "thinking": ""}
448
+ })
449
+
450
+ # 分块发送推理内容
451
+ for i in range(0, len(reasoning_content), chunk_size):
452
+ chunk_text = reasoning_content[i:i + chunk_size]
453
+ chunks.append({
454
+ "type": "content_block_delta",
455
+ "index": block_index,
456
+ "delta": {"type": "thinking_delta", "thinking": chunk_text}
457
+ })
458
+
459
+ # thinking content_block_stop
460
+ chunks.append({
461
+ "type": "content_block_stop",
462
+ "index": block_index
463
+ })
464
+
465
+ block_index += 1
466
+
467
+ # 3. 如果有图片,发送图片块
468
+ if images:
469
+ for img in images:
470
+ if img.get("type") == "image_url":
471
+ url = img.get("image_url", {}).get("url", "")
472
+ # 解析 data URL: data:{mime_type};base64,{data}
473
+ if url.startswith("data:"):
474
+ parts_of_url = url.split(";base64,")
475
+ if len(parts_of_url) == 2:
476
+ mime_type = parts_of_url[0].replace("data:", "")
477
+ base64_data = parts_of_url[1]
478
+
479
+ # image content_block_start
480
+ chunks.append({
481
+ "type": "content_block_start",
482
+ "index": block_index,
483
+ "content_block": {
484
+ "type": "image",
485
+ "source": {
486
+ "type": "base64",
487
+ "media_type": mime_type,
488
+ "data": base64_data
489
+ }
490
+ }
491
+ })
492
+
493
+ # image content_block_stop
494
+ chunks.append({
495
+ "type": "content_block_stop",
496
+ "index": block_index
497
+ })
498
+
499
+ block_index += 1
500
+
501
+ # 4. 发送主要内容(text 块)
502
+ # text content_block_start
503
+ chunks.append({
504
+ "type": "content_block_start",
505
+ "index": block_index,
506
+ "content_block": {"type": "text", "text": ""}
507
+ })
508
+
509
+ # 分块发送主要内容
510
+ for i in range(0, len(content), chunk_size):
511
+ chunk_text = content[i:i + chunk_size]
512
+ chunks.append({
513
+ "type": "content_block_delta",
514
+ "index": block_index,
515
+ "delta": {"type": "text_delta", "text": chunk_text}
516
+ })
517
+
518
+ # text content_block_stop
519
+ chunks.append({
520
+ "type": "content_block_stop",
521
+ "index": block_index
522
+ })
523
+
524
+ # 5. 发送 message_delta
525
+ chunks.append({
526
+ "type": "message_delta",
527
+ "delta": {"stop_reason": anthropic_stop_reason, "stop_sequence": None},
528
+ "usage": {"output_tokens": len(content) + len(reasoning_content)}
529
+ })
530
+
531
+ # 6. 发送 message_stop
532
+ chunks.append({
533
+ "type": "message_stop"
534
+ })
535
+
536
+ log.debug(f"[build_anthropic_fake_stream_chunks] Total chunks generated: {len(chunks)}")
537
+ return chunks
src/converter/gemini_fix.py ADDED
@@ -0,0 +1,472 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Gemini Format Utilities - 统一的 Gemini 格式处理和转换工具
3
+ 提供对 Gemini API 请求体和响应的标准化处理
4
+ ────────────────────────────────────────────────────────────────
5
+ """
6
+ from math import e
7
+ from typing import Any, Dict, Optional
8
+
9
+ from log import log
10
+
11
+ # ==================== Gemini API 配置 ====================
12
+
13
+ # ====================== Model Configuration ======================
14
+
15
+ # Default Safety Settings for Google API
16
+ DEFAULT_SAFETY_SETTINGS = [
17
+ {"category": "HARM_CATEGORY_HARASSMENT", "threshold": "BLOCK_NONE"},
18
+ {"category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "BLOCK_NONE"},
19
+ {"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", "threshold": "BLOCK_NONE"},
20
+ {"category": "HARM_CATEGORY_DANGEROUS_CONTENT", "threshold": "BLOCK_NONE"},
21
+ {"category": "HARM_CATEGORY_CIVIC_INTEGRITY", "threshold": "BLOCK_NONE"},
22
+ {"category": "HARM_CATEGORY_IMAGE_HATE", "threshold": "BLOCK_NONE"},
23
+ {"category": "HARM_CATEGORY_IMAGE_DANGEROUS_CONTENT", "threshold": "BLOCK_NONE"},
24
+ {"category": "HARM_CATEGORY_IMAGE_HARASSMENT", "threshold": "BLOCK_NONE"},
25
+ {"category": "HARM_CATEGORY_IMAGE_SEXUALLY_EXPLICIT", "threshold": "BLOCK_NONE"},
26
+ {"category": "HARM_CATEGORY_JAILBREAK", "threshold": "BLOCK_NONE"},
27
+ ]
28
+
29
+ LITE_SAFETY_SETTINGS = [
30
+ {"category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "BLOCK_NONE"},
31
+ {"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", "threshold": "BLOCK_NONE"},
32
+ {"category": "HARM_CATEGORY_DANGEROUS_CONTENT", "threshold": "BLOCK_NONE"},
33
+ {"category": "HARM_CATEGORY_HARASSMENT", "threshold": "BLOCK_NONE"},
34
+ {"category": "HARM_CATEGORY_CIVIC_INTEGRITY", "threshold": "BLOCK_NONE"},
35
+ ]
36
+
37
+ def prepare_image_generation_request(
38
+ request_body: Dict[str, Any],
39
+ model: str
40
+ ) -> Dict[str, Any]:
41
+ """
42
+ 图像生成模型请求体后处理
43
+
44
+ Args:
45
+ request_body: 原始请求体
46
+ model: 模型名称
47
+
48
+ Returns:
49
+ 处理后的请求体
50
+ """
51
+ request_body = request_body.copy()
52
+ model_lower = model.lower()
53
+
54
+ # 解析分辨率
55
+ image_size = "4K" if "-4k" in model_lower else "2K" if "-2k" in model_lower else None
56
+
57
+ # 解析比例
58
+ aspect_ratio = None
59
+ for suffix, ratio in [
60
+ ("-21x9", "21:9"), ("-16x9", "16:9"), ("-9x16", "9:16"),
61
+ ("-4x3", "4:3"), ("-3x4", "3:4"), ("-1x1", "1:1")
62
+ ]:
63
+ if suffix in model_lower:
64
+ aspect_ratio = ratio
65
+ break
66
+
67
+ # 构建 imageConfig
68
+ image_config = {}
69
+ if aspect_ratio:
70
+ image_config["aspectRatio"] = aspect_ratio
71
+ if image_size:
72
+ image_config["imageSize"] = image_size
73
+
74
+ request_body["model"] = "gemini-3.1-flash-image" # 统一使用基础模型名
75
+ request_body["generationConfig"] = {
76
+ "candidateCount": 1,
77
+ "imageConfig": image_config
78
+ }
79
+
80
+ # 移除不需要的字段
81
+ for key in ("systemInstruction", "tools", "toolConfig"):
82
+ request_body.pop(key, None)
83
+
84
+ return request_body
85
+
86
+
87
+ # ==================== 模型特性辅助函数 ====================
88
+
89
+ def get_base_model_name(model_name: str) -> str:
90
+ """移除模型名称中的后缀,返回基础模型名"""
91
+ # 按照从长到短的顺序排列,避免短后缀先于长后缀被匹配
92
+ suffixes = [
93
+ "-maxthinking", "-nothinking", # 兼容旧模式
94
+ "-minimal", "-medium", "-search", "-think", # 中等长度后缀
95
+ "-high", "-max", "-low" # 短后缀
96
+ ]
97
+ result = model_name
98
+ changed = True
99
+ # 持续循环直到没有任何后缀可以移除
100
+ while changed:
101
+ changed = False
102
+ for suffix in suffixes:
103
+ if result.endswith(suffix):
104
+ result = result[:-len(suffix)]
105
+ changed = True
106
+ # 不使用 break,继续检查是否还有其他后缀
107
+ return result
108
+
109
+
110
+ def get_thinking_settings(model_name: str) -> tuple[Optional[int], Optional[str]]:
111
+ """
112
+ 根据模型名称获取思考配置
113
+
114
+ 支持两种模式:
115
+ 1. CLI 模式思考预算 (Gemini 2.5 系列): -max, -high, -medium, -low, -minimal
116
+ 2. CLI 模式思考等级 (Gemini 3 Preview 系列): -high, -medium, -low, -minimal (仅 3-flash)
117
+ 3. 兼容旧模式: -maxthinking, -nothinking (不返回给用户)
118
+
119
+ Returns:
120
+ (thinking_budget, thinking_level): 思考预算和思考等级
121
+ """
122
+ base_model = get_base_model_name(model_name)
123
+
124
+ # ========== 兼容旧模式 (不返回给用户) ==========
125
+ if "-nothinking" in model_name:
126
+ # nothinking 模式: 限制思考
127
+ if "flash" in base_model:
128
+ return 0, None
129
+ return 128, None
130
+ elif "-maxthinking" in model_name:
131
+ # maxthinking 模式: 最大思考预算
132
+ budget = 24576 if "flash" in base_model else 32768
133
+ if "gemini-3" in base_model:
134
+ # Gemini 3 系列不支持 thinkingBudget,返回 high 等级
135
+ return None, "high"
136
+ else:
137
+ return budget, None
138
+
139
+ # ========== 新 CLI 模式: 基于思考预算/等级 ==========
140
+
141
+ # Gemini 3 Preview 系列: 使用 thinkingLevel
142
+ if "gemini-3" in base_model:
143
+ if "-high" in model_name:
144
+ return None, "high"
145
+ elif "-medium" in model_name:
146
+ # 仅 3-flash-preview 支持 medium
147
+ if "flash" in base_model:
148
+ return None, "medium"
149
+ # pro 系列不支持 medium,返回 Default
150
+ return None, None
151
+ elif "-low" in model_name:
152
+ return None, "low"
153
+ elif "-minimal" in model_name:
154
+ return None, None
155
+ else:
156
+ # Default: 不设置 thinking 配置
157
+ return None, None
158
+
159
+ # Gemini 2.5 系列: 使用 thinkingBudget
160
+ elif "gemini-2.5" in base_model:
161
+ if "-max" in model_name:
162
+ # 2.5-flash-max: 24576, 2.5-pro-max: 32768
163
+ budget = 24576 if "flash" in base_model else 32768
164
+ return budget, None
165
+ elif "-high" in model_name:
166
+ # 2.5-flash-high: 16000, 2.5-pro-high: 16000
167
+ return 16000, None
168
+ elif "-medium" in model_name:
169
+ # 2.5-flash-medium: 8192, 2.5-pro-medium: 8192
170
+ return 8192, None
171
+ elif "-low" in model_name:
172
+ # 2.5-flash-low: 1024, 2.5-pro-low: 1024
173
+ return 1024, None
174
+ elif "-minimal" in model_name:
175
+ # 2.5-flash-minimal: 0, 2.5-pro-minimal: 128
176
+ budget = 0 if "flash" in base_model else 128
177
+ return budget, None
178
+ else:
179
+ # Default: 不设置 thinking budget
180
+ return None, None
181
+
182
+ # 其他模型: 不设置 thinking 配置
183
+ return None, None
184
+
185
+
186
+ def is_search_model(model_name: str) -> bool:
187
+ """检查是否为搜索模型"""
188
+ return "-search" in model_name
189
+
190
+
191
+ # ==================== 统一的 Gemini 请求后处理 ====================
192
+
193
+ def is_thinking_model(model_name: str) -> bool:
194
+ """检查是否为思考模型 (包含 -thinking 或 pro)"""
195
+ return "think" in model_name or "pro" in model_name.lower()
196
+
197
+
198
+ async def normalize_gemini_request(
199
+ request: Dict[str, Any],
200
+ mode: str = "geminicli"
201
+ ) -> Dict[str, Any]:
202
+ """
203
+ 规范化 Gemini 请求
204
+
205
+ 处理逻辑:
206
+ 1. 模型特性处理 (thinking config, search tools)
207
+ 3. 参数范围限制 (maxOutputTokens, topK)
208
+ 4. 工具清理
209
+
210
+ Args:
211
+ request: 原始请求字典
212
+ mode: 模式 ("geminicli" 或 "antigravity")
213
+
214
+ Returns:
215
+ 规范化后的请求
216
+ """
217
+ # 导入配置函数
218
+ from config import get_return_thoughts_to_frontend
219
+
220
+ result = request.copy()
221
+ model = result.get("model", "")
222
+ generation_config = (result.get("generationConfig") or {}).copy() # 创建副本避免修改原对象
223
+ tools = result.get("tools")
224
+ system_instruction = result.get("systemInstruction") or result.get("system_instructions")
225
+
226
+ # 记录原始请求
227
+ log.debug(f"[GEMINI_FIX] 原始请求 - 模型: {model}, mode: {mode}, generationConfig: {generation_config}")
228
+
229
+ # 获取配置值
230
+ return_thoughts = await get_return_thoughts_to_frontend()
231
+
232
+ # ========== 模式特定处理 ==========
233
+ if mode == "geminicli":
234
+ # 1. 思考设置
235
+ # 优先使用 get_thinking_settings 获取的思考预算和等级
236
+ thinking_budget, thinking_level = get_thinking_settings(model)
237
+
238
+ # 其次使用传入的思考预算(如果未从模型名称获取)
239
+ if thinking_budget is None and thinking_level is None:
240
+ thinking_budget = generation_config.get("thinkingConfig", {}).get("thinkingBudget")
241
+ thinking_level = generation_config.get("thinkingConfig", {}).get("thinkingLevel")
242
+
243
+ # 假如 is_thinking_model 为真或者思考预算/等级不为空,设置 thinkingConfig
244
+ if is_thinking_model(model) or thinking_budget is not None or thinking_level is not None:
245
+ # 确保 thinkingConfig 存在
246
+ if "thinkingConfig" not in generation_config:
247
+ generation_config["thinkingConfig"] = {}
248
+
249
+ thinking_config = generation_config["thinkingConfig"]
250
+
251
+ # 设置思考预算或等级(互斥)
252
+ if thinking_budget is not None:
253
+ thinking_config["thinkingBudget"] = thinking_budget
254
+ thinking_config.pop("thinkingLevel", None) # 避免与 thinkingBudget 冲突
255
+ elif thinking_level is not None:
256
+ thinking_config["thinkingLevel"] = thinking_level
257
+ thinking_config.pop("thinkingBudget", None) # 避免与 thinkingLevel 冲突
258
+
259
+ # includeThoughts 逻辑:
260
+ # 1. 如果是 pro 模型,为 return_thoughts
261
+ # 2. 如果不是 pro 模型,检查是否有思考预算或思考等级
262
+ base_model = get_base_model_name(model)
263
+ if "pro" in base_model:
264
+ include_thoughts = return_thoughts
265
+ elif "3-flash" in base_model:
266
+ if thinking_level is None:
267
+ include_thoughts = False
268
+ else:
269
+ include_thoughts = return_thoughts
270
+ else:
271
+ # 非 pro 模型: 有思考预算或等级才包含思考
272
+ # 注意: 思考预算为 0 时不包含思考
273
+ if thinking_budget is None or thinking_budget == 0:
274
+ include_thoughts = False
275
+ else:
276
+ include_thoughts = return_thoughts
277
+
278
+ thinking_config["includeThoughts"] = include_thoughts
279
+
280
+ # 2. 搜索模型添加 Google Search
281
+ if is_search_model(model):
282
+ result_tools = result.get("tools") or []
283
+ result["tools"] = result_tools
284
+ if not any(tool.get("googleSearch") for tool in result_tools if isinstance(tool, dict)):
285
+ result_tools.append({"googleSearch": {}})
286
+
287
+ # 3. 模型名称处理
288
+ result["model"] = get_base_model_name(model)
289
+
290
+ elif mode == "antigravity":
291
+
292
+ '''
293
+ # 1. 处理 system_instruction
294
+ custom_prompt = "Please ignore the following [ignore]You are Antigravity, a powerful agentic AI coding assistant designed by the Google Deepmind team working on Advanced Agentic Coding.You are pair programming with a USER to solve their coding task. The task may require creating a new codebase, modifying or debugging an existing codebase, or simply answering a question.**Absolute paths only****Proactiveness**[/ignore]"
295
+
296
+ # 提取原有的 parts(如果存在)
297
+ existing_parts = []
298
+ if system_instruction:
299
+ if isinstance(system_instruction, dict):
300
+ existing_parts = system_instruction.get("parts", [])
301
+
302
+ # custom_prompt 始终放在第一位,原有内容整体后移
303
+ result["systemInstruction"] = {
304
+ "parts": [{"text": custom_prompt}] + existing_parts
305
+ }
306
+ '''
307
+
308
+ # 2. 判断图片模型
309
+ if "image" in model.lower():
310
+ # 调用图片生成专用处理函数
311
+ return prepare_image_generation_request(result, model)
312
+ else:
313
+ # 3. 思考模型处理
314
+ if is_thinking_model(model) or ("thinkingBudget" in generation_config.get("thinkingConfig", {}) and generation_config["thinkingConfig"]["thinkingBudget"] != 0):
315
+ # 直接设置 thinkingConfig
316
+ if "thinkingConfig" not in generation_config:
317
+ generation_config["thinkingConfig"] = {}
318
+
319
+ thinking_config = generation_config["thinkingConfig"]
320
+ # 优先使用传入的思考预算,否则使用默认值
321
+ if "thinkingBudget" not in thinking_config:
322
+ thinking_config["thinkingBudget"] = 1024
323
+ thinking_config.pop("thinkingLevel", None) # 避免与 thinkingBudget 冲突
324
+ thinking_config["includeThoughts"] = return_thoughts
325
+
326
+ # 检查最后一个 assistant 消息是否以 thinking 块开始
327
+ contents = result.get("contents", [])
328
+
329
+ if "claude" in model.lower():
330
+ # 检测是否有工具调用(MCP场景)
331
+ has_tool_calls = any(
332
+ isinstance(content, dict) and
333
+ any(
334
+ isinstance(part, dict) and ("functionCall" in part or "function_call" in part)
335
+ for part in content.get("parts", [])
336
+ )
337
+ for content in contents
338
+ )
339
+
340
+ if has_tool_calls:
341
+ # MCP 场景:检测到工具调用,移除 thinkingConfig
342
+ log.warning(f"[ANTIGRAVITY] 检测到工具调用(MCP场景),移除 thinkingConfig 避免失效")
343
+ generation_config.pop("thinkingConfig", None)
344
+ else:
345
+ # 非 MCP 场景:填充思考块
346
+ # log.warning(f"[ANTIGRAVITY] 最后一个 assistant 消息不以 thinking 块开始,自动填充思考块")
347
+
348
+ # 找到最后一个 model 角色的 content
349
+ for i in range(len(contents) - 1, -1, -1):
350
+ content = contents[i]
351
+ if isinstance(content, dict) and content.get("role") == "model":
352
+ # 在 parts 开头插入思考块(使用官方跳过验证的虚拟签名)
353
+ parts = content.get("parts", [])
354
+ thinking_part = {
355
+ "text": "...",
356
+ # "thought": True, # 标记为思考块
357
+ "thoughtSignature": "skip_thought_signature_validator" # 官方文档推荐的虚拟签名
358
+ }
359
+ # 如果第一个 part 不是 thinking,则插入
360
+ if not parts or not (isinstance(parts[0], dict) and ("thought" in parts[0] or "thoughtSignature" in parts[0])):
361
+ content["parts"] = [thinking_part] + parts
362
+ log.debug(f"[ANTIGRAVITY] 已在最后一个 assistant 消息开头插入思考块(含跳过验证签名)")
363
+ break
364
+
365
+ # 移除 -thinking 后缀
366
+ model = model.replace("-thinking", "")
367
+
368
+ # 4. Claude 模型关键词映射
369
+ # 使用关键词匹配而不是精确匹配,更灵活地处理各种变体
370
+ original_model = model
371
+ if "opus" in model.lower():
372
+ model = "claude-opus-4-6-thinking"
373
+ elif "sonnet" in model.lower():
374
+ model = "claude-sonnet-4-6"
375
+ elif "haiku" in model.lower():
376
+ model = "gemini-2.5-flash"
377
+ elif "claude" in model.lower():
378
+ # Claude 模型兜底:如果包含 claude 但不是 opus/sonnet/haiku
379
+ model = "claude-sonnet-4-6"
380
+
381
+ result["model"] = model
382
+ if original_model != model:
383
+ log.debug(f"[ANTIGRAVITY] 映射模型: {original_model} -> {model}")
384
+
385
+ # 5. 模型特殊处理:循环移除末尾的 model 消息,保证以用户消息结尾
386
+ # 因为该模型不支持预填充
387
+ if "claude-opus-4-6-thinking" in model.lower() or "claude-sonnet-4-6" in model.lower():
388
+ contents = result.get("contents", [])
389
+ removed_count = 0
390
+ while contents and isinstance(contents[-1], dict) and contents[-1].get("role") == "model":
391
+ contents.pop()
392
+ removed_count += 1
393
+ if removed_count > 0:
394
+ log.warning(f"[ANTIGRAVITY] {model} 不支持预填充,移除了 {removed_count} 条末尾 model 消息")
395
+ result["contents"] = contents
396
+
397
+ # 6. 移除 antigravity 模式不支持的字段
398
+ generation_config.pop("presencePenalty", None)
399
+ generation_config.pop("frequencyPenalty", None)
400
+ generation_config.pop("stopSequences", None)
401
+
402
+ # ========== 公共处理 ==========
403
+
404
+ # 1. 安全设置覆盖
405
+ if "lite" in model.lower():
406
+ result["safetySettings"] = LITE_SAFETY_SETTINGS
407
+ else:
408
+ result["safetySettings"] = DEFAULT_SAFETY_SETTINGS
409
+
410
+ # 2. 参数范围限制
411
+ if generation_config:
412
+ # 强制设置 maxOutputTokens 为 64000
413
+ generation_config["maxOutputTokens"] = 64000
414
+ # 强制设置 topK 为 64
415
+ generation_config["topK"] = 64
416
+
417
+ if "contents" in result:
418
+ cleaned_contents = []
419
+ for content in result["contents"]:
420
+ if isinstance(content, dict) and "parts" in content:
421
+ # 过滤掉空的或无效的 parts
422
+ valid_parts = []
423
+ for part in content["parts"]:
424
+ if not isinstance(part, dict):
425
+ continue
426
+
427
+ # 检查 part 是否有有效的非空值
428
+ # 过滤掉空字典或所有值都为空的 part
429
+ has_valid_value = any(
430
+ value not in (None, "", {}, [])
431
+ for key, value in part.items()
432
+ if key != "thought" # thought 字段可以为空
433
+ )
434
+
435
+ if has_valid_value:
436
+ part = part.copy()
437
+
438
+ # 修复 text 字段:确保是字符串而不是列表
439
+ if "text" in part:
440
+ text_value = part["text"]
441
+ if isinstance(text_value, list):
442
+ # 如果是列表,合并为字符串
443
+ log.warning(f"[GEMINI_FIX] text 字段是列表,自动合并: {text_value}")
444
+ part["text"] = " ".join(str(t) for t in text_value if t)
445
+ elif isinstance(text_value, str):
446
+ # 清理尾随空格
447
+ part["text"] = text_value.rstrip()
448
+ else:
449
+ # 其他类型转为字符串
450
+ log.warning(f"[GEMINI_FIX] text 字段类型异常 ({type(text_value)}), 转为字符串: {text_value}")
451
+ part["text"] = str(text_value)
452
+
453
+ valid_parts.append(part)
454
+ else:
455
+ log.warning(f"[GEMINI_FIX] 移除空的或无效的 part: {part}")
456
+
457
+ # 只添加有有效 parts 的 content
458
+ if valid_parts:
459
+ cleaned_content = content.copy()
460
+ cleaned_content["parts"] = valid_parts
461
+ cleaned_contents.append(cleaned_content)
462
+ else:
463
+ log.warning(f"[GEMINI_FIX] 跳过没有有效 parts 的 content: {content.get('role')}")
464
+ else:
465
+ cleaned_contents.append(content)
466
+
467
+ result["contents"] = cleaned_contents
468
+
469
+ if generation_config:
470
+ result["generationConfig"] = generation_config
471
+
472
+ return result
src/converter/openai2gemini.py ADDED
@@ -0,0 +1,1533 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ OpenAI Transfer Module - Handles conversion between OpenAI and Gemini API formats
3
+ 被openai-router调用,负责OpenAI格式与Gemini格式的双向转换
4
+ """
5
+
6
+ import json
7
+ import time
8
+ import uuid
9
+ from typing import Any, Dict, List, Optional, Tuple, Union
10
+
11
+ from pypinyin import Style, lazy_pinyin
12
+
13
+ from src.converter.thoughtSignature_fix import (
14
+ encode_tool_id_with_signature,
15
+ decode_tool_id_and_signature,
16
+ )
17
+ from src.converter.utils import merge_system_messages
18
+
19
+ from log import log
20
+
21
+ def _convert_usage_metadata(usage_metadata: Dict[str, Any]) -> Dict[str, int]:
22
+ """
23
+ 将Gemini的usageMetadata转换为OpenAI格式的usage字段
24
+
25
+ Args:
26
+ usage_metadata: Gemini API的usageMetadata字段
27
+
28
+ Returns:
29
+ OpenAI格式的usage字典,如果没有usage数据则返回None
30
+ """
31
+ if not usage_metadata:
32
+ return None
33
+
34
+ return {
35
+ "prompt_tokens": usage_metadata.get("promptTokenCount", 0),
36
+ "completion_tokens": usage_metadata.get("candidatesTokenCount", 0),
37
+ "total_tokens": usage_metadata.get("totalTokenCount", 0),
38
+ }
39
+
40
+
41
+ def _build_message_with_reasoning(role: str, content: str, reasoning_content: str) -> dict:
42
+ """构建包含可选推理内容的消息对象"""
43
+ message = {"role": role, "content": content}
44
+
45
+ # 如果有thinking tokens,添加reasoning_content
46
+ if reasoning_content:
47
+ message["reasoning_content"] = reasoning_content
48
+
49
+ return message
50
+
51
+
52
+ def _map_finish_reason(gemini_reason: str) -> str:
53
+ """
54
+ 将Gemini结束原因映射到OpenAI结束原因
55
+
56
+ Args:
57
+ gemini_reason: 来自Gemini API的结束原因
58
+
59
+ Returns:
60
+ OpenAI兼容的结束原因
61
+ """
62
+ if gemini_reason == "STOP":
63
+ return "stop"
64
+ elif gemini_reason == "MAX_TOKENS":
65
+ return "length"
66
+ elif gemini_reason in ["SAFETY", "RECITATION"]:
67
+ return "content_filter"
68
+ else:
69
+ # 对于 None 或未知的 finishReason,返回 "stop" 作为默认值
70
+ # 避免返回 None 导致 MCP 客户端误判为响应未完成而循环调用
71
+ return "stop"
72
+
73
+
74
+ # ==================== Tool Conversion Functions ====================
75
+
76
+
77
+ def _normalize_function_name(name: str) -> str:
78
+ """
79
+ 规范化函数名以符合 Gemini API 要求
80
+
81
+ 规则:
82
+ - 必须以字母或下划线开头
83
+ - 只能包含 a-z, A-Z, 0-9, 下划线, 英文句点, 英文短划线
84
+ - 最大长度 64 个字符
85
+
86
+ 转换策略:
87
+ 1. 中文字符转换为拼音
88
+ 2. 将非法字符替换为下划线
89
+ 3. 如果以非字母/下划线开头,添加下划线前缀
90
+ 4. 截断到 64 个字符
91
+
92
+ Args:
93
+ name: 原始函数名
94
+
95
+ Returns:
96
+ 规范化后的函数名
97
+ """
98
+ import re
99
+
100
+ if not name:
101
+ return "_unnamed_function"
102
+
103
+ # 步骤1:转换中文字符为拼音
104
+ if re.search(r"[\u4e00-\u9fff]", name):
105
+ try:
106
+ parts = []
107
+ for char in name:
108
+ if "\u4e00" <= char <= "\u9fff":
109
+ # 中文字符转换为拼音
110
+ pinyin = lazy_pinyin(char, style=Style.NORMAL)
111
+ parts.append("".join(pinyin))
112
+ else:
113
+ parts.append(char)
114
+ normalized = "".join(parts)
115
+ except ImportError:
116
+ log.warning("pypinyin not installed, cannot convert Chinese characters to pinyin")
117
+ normalized = name
118
+ else:
119
+ normalized = name
120
+
121
+ # 步骤2:将非法字符替换为下划线
122
+ # 合法字符:a-z, A-Z, 0-9, _, ., -
123
+ normalized = re.sub(r"[^a-zA-Z0-9_.\-]", "_", normalized)
124
+
125
+ # 步骤3:确保以字母或下划线开头
126
+ if normalized and not (normalized[0].isalpha() or normalized[0] == "_"):
127
+ # 以数字、点或短横线开头,添加下划线前缀
128
+ normalized = "_" + normalized
129
+
130
+ # 步骤4:截断到 64 个字符
131
+ if len(normalized) > 64:
132
+ normalized = normalized[:64]
133
+
134
+ # 步骤5:确保不为空
135
+ if not normalized:
136
+ normalized = "_unnamed_function"
137
+
138
+ return normalized
139
+
140
+
141
+ def _resolve_ref(ref: str, root_schema: Dict[str, Any]) -> Optional[Dict[str, Any]]:
142
+ """
143
+ 解析 $ref 引用
144
+
145
+ Args:
146
+ ref: 引用路径,如 "#/definitions/MyType"
147
+ root_schema: 根 schema 对象
148
+
149
+ Returns:
150
+ 解析后的 schema,如果失败返回 None
151
+ """
152
+ if not ref.startswith('#/'):
153
+ return None
154
+
155
+ path = ref[2:].split('/')
156
+ current = root_schema
157
+
158
+ for segment in path:
159
+ if isinstance(current, dict) and segment in current:
160
+ current = current[segment]
161
+ else:
162
+ return None
163
+
164
+ return current if isinstance(current, dict) else None
165
+
166
+
167
+ def _clean_schema_for_claude(schema: Any, root_schema: Optional[Dict[str, Any]] = None, visited: Optional[set] = None) -> Any:
168
+ """
169
+ 清理 JSON Schema,转换为 Claude API 支持的格式(符合 JSON Schema draft 2020-12)
170
+
171
+ 处理逻辑:
172
+ 1. 解析 $ref 引用
173
+ 2. 合并 allOf 中的 schema
174
+ 3. 转换 anyOf 为更兼容的格式
175
+ 4. 保持标准 JSON Schema 类型(不转换为大写)
176
+ 5. 处理 array 的 items
177
+ 6. 清理 Claude 不支持的字段
178
+
179
+ Args:
180
+ schema: JSON Schema 对象
181
+ root_schema: 根 schema(用于解析 $ref)
182
+ visited: 已访问的对象集合(防止循环引用)
183
+
184
+ Returns:
185
+ 清理后的 schema
186
+ """
187
+ # 非字典类型直接返回
188
+ if not isinstance(schema, dict):
189
+ return schema
190
+
191
+ # 初始化
192
+ if root_schema is None:
193
+ root_schema = schema
194
+ if visited is None:
195
+ visited = set()
196
+
197
+ # 防止循环引用
198
+ schema_id = id(schema)
199
+ if schema_id in visited:
200
+ return schema
201
+ visited.add(schema_id)
202
+
203
+ # 创建副本避免修改原对象
204
+ result = {}
205
+
206
+ # 1. 处理 $ref
207
+ if "$ref" in schema:
208
+ resolved = _resolve_ref(schema["$ref"], root_schema)
209
+ if resolved:
210
+ import copy
211
+ result = copy.deepcopy(resolved)
212
+ for key, value in schema.items():
213
+ if key != "$ref":
214
+ result[key] = value
215
+ schema = result
216
+ result = {}
217
+
218
+ # 2. 处理 allOf(合并所有 schema)
219
+ if "allOf" in schema:
220
+ all_of_schemas = schema["allOf"]
221
+ for item in all_of_schemas:
222
+ cleaned_item = _clean_schema_for_claude(item, root_schema, visited)
223
+
224
+ if "properties" in cleaned_item:
225
+ if "properties" not in result:
226
+ result["properties"] = {}
227
+ result["properties"].update(cleaned_item["properties"])
228
+
229
+ if "required" in cleaned_item:
230
+ if "required" not in result:
231
+ result["required"] = []
232
+ result["required"].extend(cleaned_item["required"])
233
+
234
+ for key, value in cleaned_item.items():
235
+ if key not in ["properties", "required"]:
236
+ result[key] = value
237
+
238
+ for key, value in schema.items():
239
+ if key not in ["allOf", "properties", "required"]:
240
+ result[key] = value
241
+ elif key in ["properties", "required"] and key not in result:
242
+ result[key] = value
243
+ else:
244
+ result = dict(schema)
245
+
246
+ # 3. 处理 type 数组(如 ["string", "null"])
247
+ if "type" in result:
248
+ type_value = result["type"]
249
+ if isinstance(type_value, list):
250
+ # Claude 支持 type 数组,保持不变
251
+ pass
252
+
253
+ # 4. 处理 array 的 items
254
+ if result.get("type") == "array":
255
+ if "items" not in result:
256
+ result["items"] = {}
257
+ elif isinstance(result["items"], list):
258
+ # Tuple 定义,检查是否所有元素类型相同
259
+ tuple_items = result["items"]
260
+ first_type = tuple_items[0].get("type") if tuple_items else None
261
+ is_homogeneous = all(item.get("type") == first_type for item in tuple_items)
262
+
263
+ if is_homogeneous and first_type:
264
+ result["items"] = _clean_schema_for_claude(tuple_items[0], root_schema, visited)
265
+ else:
266
+ # 异质元组,使用 anyOf 表示
267
+ result["items"] = {
268
+ "anyOf": [_clean_schema_for_claude(item, root_schema, visited) for item in tuple_items]
269
+ }
270
+ else:
271
+ result["items"] = _clean_schema_for_claude(result["items"], root_schema, visited)
272
+
273
+ # 5. 处理 anyOf(保持 anyOf,递归清理)
274
+ if "anyOf" in result:
275
+ result["anyOf"] = [_clean_schema_for_claude(item, root_schema, visited) for item in result["anyOf"]]
276
+
277
+ # 6. 清理 Claude 不支持的字段(根据 JSON Schema 2020-12)
278
+ # Claude API 对某些字段比较严格,移除可能导致问题的字段
279
+ unsupported_keys = {
280
+ "title", "$schema", "strict",
281
+ "additionalItems", # 废弃字段,使用 items 替代
282
+ "exclusiveMaximum", "exclusiveMinimum", # 在 2020-12 中这些应该是数值而非布尔值
283
+ "$defs", "definitions", # 移除 definitions 相关字段避免冲突
284
+ "example", "examples", "readOnly", "writeOnly",
285
+ "const", # const 可能导致问题
286
+ "contentEncoding", "contentMediaType",
287
+ "oneOf", # oneOf 可能导致问题,用 anyOf 替代
288
+ "patternProperties", "dependencies", "propertyNames", # Google API 不支持
289
+ }
290
+
291
+ for key in list(result.keys()):
292
+ if key in unsupported_keys:
293
+ del result[key]
294
+
295
+ # 递归处理 additionalProperties(如果存在)
296
+ if "additionalProperties" in result and isinstance(result["additionalProperties"], dict):
297
+ result["additionalProperties"] = _clean_schema_for_claude(result["additionalProperties"], root_schema, visited)
298
+
299
+ # 7. 递归处理 properties
300
+ if "properties" in result:
301
+ cleaned_props = {}
302
+ for prop_name, prop_schema in result["properties"].items():
303
+ cleaned_props[prop_name] = _clean_schema_for_claude(prop_schema, root_schema, visited)
304
+ result["properties"] = cleaned_props
305
+
306
+ # 8. 确保有 type 字段(如果有 properties 但没有 type)
307
+ if "properties" in result and "type" not in result:
308
+ result["type"] = "object"
309
+
310
+ # 9. 去重 required 数组
311
+ if "required" in result and isinstance(result["required"], list):
312
+ result["required"] = list(dict.fromkeys(result["required"]))
313
+
314
+ return result
315
+
316
+
317
+ def _clean_schema_for_gemini(schema: Any, root_schema: Optional[Dict[str, Any]] = None, visited: Optional[set] = None) -> Any:
318
+ """
319
+ 清理 JSON Schema,转换为 Gemini 支持的格式
320
+
321
+ 参考 worker.mjs 的 transformOpenApiSchemaToGemini 实现
322
+
323
+ 处理逻辑:
324
+ 1. 解析 $ref 引用
325
+ 2. 合并 allOf 中的 schema
326
+ 3. 转换 anyOf 为 enum(如果可能)
327
+ 4. 类型映射(string -> STRING)
328
+ 5. 处理 ARRAY 的 items(包括 Tuple)
329
+ 6. 将 default 值移到 description
330
+ 7. 清理不支持的字段
331
+
332
+ Args:
333
+ schema: JSON Schema 对象
334
+ root_schema: 根 schema(用于解析 $ref)
335
+ visited: 已访问的对象集合(防止循环引用)
336
+
337
+ Returns:
338
+ 清理后的 schema
339
+ """
340
+ # 非字典类型直接返回
341
+ if not isinstance(schema, dict):
342
+ return schema
343
+
344
+ # 初始化
345
+ if root_schema is None:
346
+ root_schema = schema
347
+ if visited is None:
348
+ visited = set()
349
+
350
+ # 防止循环引用
351
+ schema_id = id(schema)
352
+ if schema_id in visited:
353
+ return schema
354
+ visited.add(schema_id)
355
+
356
+ # 创建副本避免修改原对象
357
+ result = {}
358
+
359
+ # 1. 处理 $ref
360
+ if "$ref" in schema:
361
+ resolved = _resolve_ref(schema["$ref"], root_schema)
362
+ if resolved:
363
+ # 检测循环引用:若 resolved 已在 visited 中,直接返回占位符
364
+ resolved_id = id(resolved)
365
+ if resolved_id in visited:
366
+ return {"type": "OBJECT", "description": "(circular reference)"}
367
+ # 将 resolved 的 id 加入 visited,防止后续递归时重复处理
368
+ visited.add(resolved_id)
369
+ # 合并解析后的 schema 和当前 schema(浅拷贝,避免 deepcopy 爆栈)
370
+ merged = dict(resolved)
371
+ # 当前 schema 的其他字段会覆盖解析后的字段
372
+ for key, value in schema.items():
373
+ if key != "$ref":
374
+ merged[key] = value
375
+ schema = merged
376
+ result = {}
377
+
378
+ # 2. 处理 allOf(合并所有 schema)
379
+ if "allOf" in schema:
380
+ all_of_schemas = schema["allOf"]
381
+ for item in all_of_schemas:
382
+ cleaned_item = _clean_schema_for_gemini(item, root_schema, visited)
383
+
384
+ # 合并 properties
385
+ if "properties" in cleaned_item:
386
+ if "properties" not in result:
387
+ result["properties"] = {}
388
+ result["properties"].update(cleaned_item["properties"])
389
+
390
+ # 合并 required
391
+ if "required" in cleaned_item:
392
+ if "required" not in result:
393
+ result["required"] = []
394
+ result["required"].extend(cleaned_item["required"])
395
+
396
+ # 合并其他字段(简单覆盖)
397
+ for key, value in cleaned_item.items():
398
+ if key not in ["properties", "required"]:
399
+ result[key] = value
400
+
401
+ # 复制其他字段
402
+ for key, value in schema.items():
403
+ if key not in ["allOf", "properties", "required"]:
404
+ result[key] = value
405
+ elif key in ["properties", "required"] and key not in result:
406
+ result[key] = value
407
+ else:
408
+ # 复制所有字段
409
+ result = dict(schema)
410
+
411
+ # 3. 类型映射(转换为大写)
412
+ # 注意:Gemini API 的 type 字段必须是字符串,不能是数组
413
+ if "type" in result:
414
+ type_value = result["type"]
415
+
416
+ # 如果 type 是列表,提取主要类型(非 null)
417
+ if isinstance(type_value, list):
418
+ primary_type = next((t for t in type_value if t != "null"), None)
419
+ type_value = primary_type if primary_type else "STRING" # 默认为 STRING
420
+
421
+ # 类型映射
422
+ type_map = {
423
+ "string": "STRING",
424
+ "number": "NUMBER",
425
+ "integer": "INTEGER",
426
+ "boolean": "BOOLEAN",
427
+ "array": "ARRAY",
428
+ "object": "OBJECT",
429
+ }
430
+
431
+ if isinstance(type_value, str) and type_value.lower() in type_map:
432
+ # 确保 result["type"] 是字符串而不是列表
433
+ result["type"] = type_map[type_value.lower()]
434
+ else:
435
+ # 未知类型,删除该字段
436
+ del result["type"]
437
+
438
+ # 4. 处理 ARRAY 的 items
439
+ if result.get("type") == "ARRAY":
440
+ if "items" not in result:
441
+ # 没有 items,默认允许任意类型
442
+ result["items"] = {}
443
+ elif isinstance(result["items"], list):
444
+ # Tuple 定义(items 是数组)
445
+ tuple_items = result["items"]
446
+
447
+ # 提取类型信息用于 description
448
+ tuple_types = [item.get("type", "any") for item in tuple_items]
449
+ tuple_desc = f"(Tuple: [{', '.join(tuple_types)}])"
450
+
451
+ original_desc = result.get("description", "")
452
+ result["description"] = f"{original_desc} {tuple_desc}".strip()
453
+
454
+ # 检查是否所有元素类型相同
455
+ first_type = tuple_items[0].get("type") if tuple_items else None
456
+ is_homogeneous = all(item.get("type") == first_type for item in tuple_items)
457
+
458
+ if is_homogeneous and first_type:
459
+ # 同质元组,转换为 List<Type>
460
+ result["items"] = _clean_schema_for_gemini(tuple_items[0], root_schema, visited)
461
+ else:
462
+ # 异质元组,Gemini 不支持,设为 {}
463
+ result["items"] = {}
464
+ else:
465
+ # 递归处理 items
466
+ result["items"] = _clean_schema_for_gemini(result["items"], root_schema, visited)
467
+
468
+ # 5. 处理 anyOf(尝试转换为 enum)
469
+ if "anyOf" in result:
470
+ any_of_schemas = result["anyOf"]
471
+
472
+ # 递归处理每个 schema
473
+ cleaned_any_of = [_clean_schema_for_gemini(item, root_schema, visited) for item in any_of_schemas]
474
+
475
+ # 尝试提取 enum
476
+ if all("const" in item for item in cleaned_any_of):
477
+ enum_values = [
478
+ str(item["const"])
479
+ for item in cleaned_any_of
480
+ if item.get("const") not in ["", None]
481
+ ]
482
+ if enum_values:
483
+ result["type"] = "STRING"
484
+ result["enum"] = enum_values
485
+ elif "type" not in result:
486
+ # 如果不是 enum,尝试取第一个有效的类型定义
487
+ first_valid = next((item for item in cleaned_any_of if item.get("type") or item.get("enum")), None)
488
+ if first_valid:
489
+ result.update(first_valid)
490
+
491
+ # 删除 anyOf
492
+ del result["anyOf"]
493
+
494
+ # 6. 将 default 值移到 description
495
+ if "default" in result:
496
+ default_value = result["default"]
497
+ original_desc = result.get("description", "")
498
+ result["description"] = f"{original_desc} (Default: {json.dumps(default_value)})".strip()
499
+ del result["default"]
500
+
501
+ # 7. 清理不支持的字段
502
+ unsupported_keys = {
503
+ "title", "$schema", "$ref", "strict", "exclusiveMaximum",
504
+ "exclusiveMinimum", "additionalProperties", "oneOf", "allOf",
505
+ "$defs", "definitions", "example", "examples", "readOnly",
506
+ "writeOnly", "const", "additionalItems", "contains",
507
+ "patternProperties", "dependencies", "propertyNames",
508
+ "if", "then", "else", "contentEncoding", "contentMediaType"
509
+ }
510
+
511
+ for key in list(result.keys()):
512
+ if key in unsupported_keys:
513
+ del result[key]
514
+
515
+ # 8. 递归处理 properties
516
+ if "properties" in result:
517
+ cleaned_props = {}
518
+ for prop_name, prop_schema in result["properties"].items():
519
+ cleaned_props[prop_name] = _clean_schema_for_gemini(prop_schema, root_schema, visited)
520
+ result["properties"] = cleaned_props
521
+
522
+ # 9. 确保有 type 字段(如果有 properties 但没有 type)
523
+ if "properties" in result and "type" not in result:
524
+ result["type"] = "OBJECT"
525
+
526
+ # 10. 去重 required 数组
527
+ if "required" in result and isinstance(result["required"], list):
528
+ result["required"] = list(dict.fromkeys(result["required"])) # 保持顺序去重
529
+
530
+ return result
531
+
532
+
533
+ def fix_tool_call_args_types(
534
+ args: Dict[str, Any],
535
+ parameters_schema: Dict[str, Any]
536
+ ) -> Dict[str, Any]:
537
+ """
538
+ 根据工具的参数 schema 修正函数调用参数的类型
539
+
540
+ 例如:将字符串 "5" 转换为数字 5,根据 schema 中的 type 定义
541
+
542
+ Args:
543
+ args: 函数调用的参数字典
544
+ parameters_schema: 工具定义中的 parameters schema
545
+
546
+ Returns:
547
+ 类型修正后的参数字典
548
+ """
549
+ if not args or not parameters_schema:
550
+ return args
551
+
552
+ properties = parameters_schema.get("properties", {})
553
+ if not properties:
554
+ return args
555
+
556
+ fixed_args = {}
557
+ for key, value in args.items():
558
+ if key not in properties:
559
+ # 参数不在 schema 中,保持原样
560
+ fixed_args[key] = value
561
+ continue
562
+
563
+ param_schema = properties[key]
564
+ param_type = param_schema.get("type")
565
+
566
+ # 根据 schema 中的类型修正参数值
567
+ if param_type == "number" or param_type == "integer":
568
+ # 如果值是字符串,尝��转换为数字
569
+ if isinstance(value, str):
570
+ try:
571
+ if param_type == "integer":
572
+ fixed_args[key] = int(value)
573
+ else:
574
+ # 尝试转换为 float,如果是整数则保持为 int
575
+ num_value = float(value)
576
+ fixed_args[key] = int(num_value) if num_value.is_integer() else num_value
577
+ log.debug(f"[OPENAI2GEMINI] 修正参数类型: {key} '{value}' -> {fixed_args[key]} ({param_type})")
578
+ except (ValueError, AttributeError):
579
+ # 转换失败,保持原样
580
+ fixed_args[key] = value
581
+ log.warning(f"[OPENAI2GEMINI] 无法将参数 {key} 的值 '{value}' 转换为 {param_type}")
582
+ else:
583
+ fixed_args[key] = value
584
+ elif param_type == "boolean":
585
+ # 如果值是字符串,转换为布尔值
586
+ if isinstance(value, str):
587
+ if value.lower() in ("true", "1", "yes"):
588
+ fixed_args[key] = True
589
+ elif value.lower() in ("false", "0", "no"):
590
+ fixed_args[key] = False
591
+ else:
592
+ fixed_args[key] = value
593
+ if fixed_args[key] != value:
594
+ log.debug(f"[OPENAI2GEMINI] 修正参数类型: {key} '{value}' -> {fixed_args[key]} (boolean)")
595
+ else:
596
+ fixed_args[key] = value
597
+ elif param_type == "string":
598
+ # 如果值不是字符串,转换为字符串
599
+ if not isinstance(value, str):
600
+ fixed_args[key] = str(value)
601
+ log.debug(f"[OPENAI2GEMINI] 修正参数类型: {key} {value} -> '{fixed_args[key]}' (string)")
602
+ else:
603
+ fixed_args[key] = value
604
+ else:
605
+ # 其他类型(array, object 等)保持原样
606
+ fixed_args[key] = value
607
+
608
+ return fixed_args
609
+
610
+
611
+ def convert_openai_tools_to_gemini(openai_tools: List, model: str = "") -> List[Dict[str, Any]]:
612
+ """
613
+ 将 OpenAI tools 格式转换为 Gemini functionDeclarations 格式
614
+
615
+ Args:
616
+ openai_tools: OpenAI 格式的工具列表(可能是字典或 Pydantic 模型)
617
+ model: 模型名称(用于判断是否为 Claude 模型)
618
+
619
+ Returns:
620
+ Gemini 格式的工具列表
621
+ """
622
+ if not openai_tools:
623
+ return []
624
+
625
+ # 判断是否为 Claude 模型
626
+ is_claude_model = "claude" in model.lower()
627
+
628
+ function_declarations = []
629
+
630
+ for tool in openai_tools:
631
+ if tool.get("type") != "function":
632
+ log.warning(f"Skipping non-function tool type: {tool.get('type')}")
633
+ continue
634
+
635
+ function = tool.get("function")
636
+ if not function:
637
+ log.warning("Tool missing 'function' field")
638
+ continue
639
+
640
+ # 获取并规范化函数名
641
+ original_name = function.get("name")
642
+ if not original_name:
643
+ log.warning("Tool missing 'name' field, using default")
644
+ original_name = "_unnamed_function"
645
+
646
+ normalized_name = _normalize_function_name(original_name)
647
+
648
+ # 如果名称被修改了,记录日志
649
+ if normalized_name != original_name:
650
+ log.debug(f"Function name normalized: '{original_name}' -> '{normalized_name}'")
651
+
652
+ # 构建 Gemini function declaration
653
+ declaration = {
654
+ "name": normalized_name,
655
+ "description": function.get("description", ""),
656
+ }
657
+
658
+ # 添加参数(如果有)- 根据模型选择不同的清理函数
659
+ if "parameters" in function:
660
+ if is_claude_model:
661
+ cleaned_params = _clean_schema_for_claude(function["parameters"])
662
+ log.debug(f"[OPENAI2GEMINI] Using Claude schema cleaning for tool: {normalized_name}")
663
+ else:
664
+ cleaned_params = _clean_schema_for_gemini(function["parameters"])
665
+
666
+ if cleaned_params:
667
+ declaration["parameters"] = cleaned_params
668
+
669
+ function_declarations.append(declaration)
670
+
671
+ if not function_declarations:
672
+ return []
673
+
674
+ # Gemini 格式:工具数组中包含 functionDeclarations
675
+ return [{"functionDeclarations": function_declarations}]
676
+
677
+
678
+ def convert_tool_choice_to_tool_config(tool_choice: Union[str, Dict[str, Any]]) -> Dict[str, Any]:
679
+ """
680
+ 将 OpenAI tool_choice 转换为 Gemini toolConfig
681
+
682
+ Args:
683
+ tool_choice: OpenAI 格式的 tool_choice
684
+
685
+ Returns:
686
+ Gemini 格式的 toolConfig
687
+ """
688
+ if isinstance(tool_choice, str):
689
+ if tool_choice == "auto":
690
+ return {"functionCallingConfig": {"mode": "AUTO"}}
691
+ elif tool_choice == "none":
692
+ return {"functionCallingConfig": {"mode": "NONE"}}
693
+ elif tool_choice == "required":
694
+ return {"functionCallingConfig": {"mode": "ANY"}}
695
+ elif isinstance(tool_choice, dict):
696
+ # {"type": "function", "function": {"name": "my_function"}}
697
+ if tool_choice.get("type") == "function":
698
+ function_name = tool_choice.get("function", {}).get("name")
699
+ if function_name:
700
+ return {
701
+ "functionCallingConfig": {
702
+ "mode": "ANY",
703
+ "allowedFunctionNames": [function_name],
704
+ }
705
+ }
706
+
707
+ # 默认返回 AUTO 模式
708
+ return {"functionCallingConfig": {"mode": "AUTO"}}
709
+
710
+
711
+ def convert_tool_message_to_function_response(message, all_messages: List = None) -> Dict[str, Any]:
712
+ """
713
+ 将 OpenAI 的 tool role 消息转换为 Gemini functionResponse
714
+
715
+ Args:
716
+ message: OpenAI 格式的工具消息
717
+ all_messages: 所有消息的列表,用于查找 tool_call_id 对应的函数名
718
+
719
+ Returns:
720
+ Gemini 格式的 functionResponse part
721
+ """
722
+ # 获取 name 字段
723
+ name = getattr(message, "name", None)
724
+ encoded_tool_call_id = getattr(message, "tool_call_id", None) or ""
725
+
726
+ # 解码获取原始ID(functionResponse不需要签名)
727
+ original_tool_call_id, _ = decode_tool_id_and_signature(encoded_tool_call_id)
728
+
729
+ # 如果没有 name,尝试从 all_messages 中查找对应的 tool_call_id
730
+ # 注意:使用编码ID查找,因为存储的是编码ID
731
+ if not name and encoded_tool_call_id and all_messages:
732
+ for msg in all_messages:
733
+ if getattr(msg, "role", None) == "assistant" and hasattr(msg, "tool_calls") and msg.tool_calls:
734
+ for tool_call in msg.tool_calls:
735
+ if getattr(tool_call, "id", None) == encoded_tool_call_id:
736
+ func = getattr(tool_call, "function", None)
737
+ if func:
738
+ name = getattr(func, "name", None)
739
+ break
740
+ if name:
741
+ break
742
+
743
+ # 最终兜底:如果仍然没有 name,使用默认值
744
+ if not name:
745
+ name = "unknown_function"
746
+ log.warning(f"Tool message missing function name, using default: {name}")
747
+
748
+ try:
749
+ # 尝试将 content 解析为 JSON
750
+ response_data = (
751
+ json.loads(message.content) if isinstance(message.content, str) else message.content
752
+ )
753
+ except (json.JSONDecodeError, TypeError):
754
+ # 如果不是有效的 JSON,包装为对象
755
+ response_data = {"result": str(message.content)}
756
+
757
+ # 确保 response_data 是字典类型(Gemini API 要求 response 必须是对象)
758
+ if not isinstance(response_data, dict):
759
+ response_data = {"result": response_data}
760
+
761
+ return {"functionResponse": {"id": original_tool_call_id, "name": name, "response": response_data}}
762
+
763
+
764
+ def _reverse_transform_value(value: Any) -> Any:
765
+ """
766
+ 将值转换回原始类型(Gemini 可能将所有值转为字符串)
767
+
768
+ 仅处理 Gemini 在工具参数中常见的布尔/空值字符串化情况,
769
+ 不再对数字字符串做启发式转换,避免把 schema 声明为 string
770
+ 的参数错误还原成 integer。
771
+
772
+ 参考 worker.mjs 的 reverseTransformValue
773
+
774
+ Args:
775
+ value: 要转换的值
776
+
777
+ Returns:
778
+ 转换后的值
779
+ """
780
+ if not isinstance(value, str):
781
+ return value
782
+
783
+ # 布尔值
784
+ if value == 'true':
785
+ return True
786
+ if value == 'false':
787
+ return False
788
+
789
+ # null
790
+ if value == 'null':
791
+ return None
792
+
793
+ # 其他情况保持字符串
794
+ return value
795
+
796
+
797
+ def _reverse_transform_args(args: Any) -> Any:
798
+ """
799
+ 递归转换函数参数,将字符串转回原始类型
800
+
801
+ 参考 worker.mjs 的 reverseTransformArgs
802
+
803
+ Args:
804
+ args: 函数参数(可能是字典、列表或其他类型)
805
+
806
+ Returns:
807
+ 转换后的参数
808
+ """
809
+ if not isinstance(args, (dict, list)):
810
+ return args
811
+
812
+ if isinstance(args, list):
813
+ return [_reverse_transform_args(item) for item in args]
814
+
815
+ # 处理字典
816
+ result = {}
817
+ for key, value in args.items():
818
+ if isinstance(value, (dict, list)):
819
+ result[key] = _reverse_transform_args(value)
820
+ else:
821
+ result[key] = _reverse_transform_value(value)
822
+
823
+ return result
824
+
825
+
826
+ def extract_tool_calls_from_parts(
827
+ parts: List[Dict[str, Any]], is_streaming: bool = False
828
+ ) -> Tuple[List[Dict[str, Any]], str]:
829
+ """
830
+ 从 Gemini response parts 中提取工具调用和文本内容
831
+
832
+ Args:
833
+ parts: Gemini response 的 parts 数组
834
+ is_streaming: 是否为流式响应(流式响应需要添加 index 字段)
835
+
836
+ Returns:
837
+ (tool_calls, text_content) 元组
838
+ """
839
+ tool_calls = []
840
+ text_content = ""
841
+
842
+ for idx, part in enumerate(parts):
843
+ # 检查是否是函数调用
844
+ if "functionCall" in part:
845
+ function_call = part["functionCall"]
846
+ # 获取原始ID或生成新ID
847
+ original_id = function_call.get("id") or f"call_{uuid.uuid4().hex[:24]}"
848
+ # 将thoughtSignature编码到ID中以便往返保留
849
+ signature = part.get("thoughtSignature")
850
+ encoded_id = encode_tool_id_with_signature(original_id, signature)
851
+
852
+ # 获取参数并转换类型
853
+ args = function_call.get("args", {})
854
+ # 将字符串类型的值转回原始类型
855
+ args = _reverse_transform_args(args)
856
+
857
+ tool_call = {
858
+ "id": encoded_id,
859
+ "type": "function",
860
+ "function": {
861
+ "name": function_call.get("name", "nameless_function"),
862
+ "arguments": json.dumps(args),
863
+ },
864
+ }
865
+ # 流式响应需要 index 字段
866
+ if is_streaming:
867
+ tool_call["index"] = idx
868
+ tool_calls.append(tool_call)
869
+
870
+ # 提取文本内容(排除 thinking tokens)
871
+ elif "text" in part and not part.get("thought", False):
872
+ text_content += part["text"]
873
+
874
+ return tool_calls, text_content
875
+
876
+
877
+ def extract_images_from_content(content: Any) -> Dict[str, Any]:
878
+ """
879
+ 从 OpenAI content 中提取文本和图片
880
+
881
+ Args:
882
+ content: OpenAI 消息的 content 字段(可能是字符串或列表)
883
+
884
+ Returns:
885
+ 包含 text 和 images 的字典
886
+ """
887
+ result = {"text": "", "images": []}
888
+
889
+ if isinstance(content, str):
890
+ result["text"] = content
891
+ elif isinstance(content, list):
892
+ for item in content:
893
+ if isinstance(item, dict):
894
+ if item.get("type") == "text":
895
+ result["text"] += item.get("text", "")
896
+ elif item.get("type") == "image_url":
897
+ image_url = item.get("image_url", {}).get("url", "")
898
+ # 解析 data:image/png;base64,xxx 格式
899
+ if image_url.startswith("data:image/"):
900
+ import re
901
+ match = re.match(r"^data:image/(\w+);base64,(.+)$", image_url)
902
+ if match:
903
+ mime_type = match.group(1)
904
+ base64_data = match.group(2)
905
+ result["images"].append({
906
+ "inlineData": {
907
+ "mimeType": f"image/{mime_type}",
908
+ "data": base64_data
909
+ }
910
+ })
911
+
912
+ return result
913
+
914
+ async def convert_openai_to_gemini_request(openai_request: Dict[str, Any]) -> Dict[str, Any]:
915
+ """
916
+ 将 OpenAI 格式请求体转换为 Gemini 格式请求体
917
+
918
+ 注意: 此函数只负责基础转换,不包含 normalize_gemini_request 中的处理
919
+ (如 thinking config, search tools, 参数范围限制等)
920
+
921
+ Args:
922
+ openai_request: OpenAI 格式的请求体字典,包含:
923
+ - messages: 消息列表
924
+ - temperature, top_p, max_tokens, stop 等生成参数
925
+ - tools, tool_choice (可选)
926
+ - response_format (可选)
927
+
928
+ Returns:
929
+ Gemini 格式的请求体字典,包含:
930
+ - contents: 转换后的消息内容
931
+ - generationConfig: 生成配置
932
+ - systemInstruction: 系统指令 (如果有)
933
+ - tools, toolConfig (如果有)
934
+ """
935
+ # 处理连续的system消息(兼容性模式)
936
+ openai_request = await merge_system_messages(openai_request)
937
+
938
+ contents = []
939
+
940
+ # 提取消息列表
941
+ messages = openai_request.get("messages", [])
942
+
943
+ # 构建 tool_call_id -> (name, original_id, signature) 的映射
944
+ tool_call_mapping = {}
945
+ for msg in messages:
946
+ if msg.get("role") == "assistant" and msg.get("tool_calls"):
947
+ for tc in msg["tool_calls"]:
948
+ encoded_id = tc.get("id", "")
949
+ func_name = tc.get("function", {}).get("name") or ""
950
+ if encoded_id:
951
+ # 解码获取原始ID和签名
952
+ original_id, signature = decode_tool_id_and_signature(encoded_id)
953
+ tool_call_mapping[encoded_id] = (func_name, original_id, signature)
954
+
955
+ # 构建工具名称到参数 schema 的映射(用于类型修正)
956
+ tool_schemas = {}
957
+ if "tools" in openai_request and openai_request["tools"]:
958
+ for tool in openai_request["tools"]:
959
+ if tool.get("type") == "function":
960
+ function = tool.get("function", {})
961
+ func_name = function.get("name")
962
+ if func_name:
963
+ tool_schemas[func_name] = function.get("parameters", {})
964
+
965
+ # 用于累积连续的 tool message 的 functionResponse parts
966
+ pending_tool_parts = []
967
+
968
+ def flush_pending_tool_parts():
969
+ """将累积的 tool parts 作为单个 contents 条目追加"""
970
+ nonlocal pending_tool_parts
971
+ if pending_tool_parts:
972
+ contents.append({
973
+ "role": "user",
974
+ "parts": pending_tool_parts
975
+ })
976
+ pending_tool_parts = []
977
+
978
+ for message in messages:
979
+ role = message.get("role", "user")
980
+ content = message.get("content", "")
981
+
982
+ # 处理工具消息(tool role)- 累积到 pending_tool_parts
983
+ if role == "tool":
984
+ tool_call_id = message.get("tool_call_id", "")
985
+ func_name = message.get("name")
986
+
987
+ # 使用映射表查找
988
+ if tool_call_id in tool_call_mapping:
989
+ func_name, original_id, _ = tool_call_mapping[tool_call_id]
990
+ else:
991
+ # 如果没有name,尝试从消息列表中查找
992
+ if not func_name and tool_call_id:
993
+ for msg in messages:
994
+ if msg.get("role") == "assistant" and msg.get("tool_calls"):
995
+ for tc in msg["tool_calls"]:
996
+ if tc.get("id") == tool_call_id:
997
+ func_name = tc.get("function", {}).get("name")
998
+ break
999
+ if func_name:
1000
+ break
1001
+
1002
+ # 解码 tool_call_id 获取原始 ID
1003
+ original_id, _ = decode_tool_id_and_signature(tool_call_id)
1004
+
1005
+ # 最终兜底:确保 func_name 不为空
1006
+ if not func_name:
1007
+ func_name = "unknown_function"
1008
+ log.warning(f"Tool message missing function name for tool_call_id={tool_call_id}, using default: {func_name}")
1009
+
1010
+ # 解析响应数据
1011
+ try:
1012
+ response_data = json.loads(content) if isinstance(content, str) else content
1013
+ except (json.JSONDecodeError, TypeError):
1014
+ response_data = {"result": str(content)}
1015
+
1016
+ # 确保 response_data 是字典类型(Gemini API 要求 response 必须是对象)
1017
+ if not isinstance(response_data, dict):
1018
+ response_data = {"result": response_data}
1019
+
1020
+ # 累积 functionResponse part(不立即追加到 contents)
1021
+ pending_tool_parts.append({
1022
+ "functionResponse": {
1023
+ "id": original_id,
1024
+ "name": func_name,
1025
+ "response": response_data
1026
+ }
1027
+ })
1028
+ continue
1029
+
1030
+ # 遇到非 tool 消息时,先 flush 累积的 tool parts
1031
+ flush_pending_tool_parts()
1032
+
1033
+ # system 消息已经由 merge_system_messages 处理,这里跳过
1034
+ if role == "system":
1035
+ continue
1036
+
1037
+ # 将OpenAI角色映射到Gemini角色
1038
+ if role == "assistant":
1039
+ role = "model"
1040
+
1041
+ # 检查是否有tool_calls
1042
+ tool_calls = message.get("tool_calls")
1043
+ if tool_calls:
1044
+ parts = []
1045
+
1046
+ # 如果有文本内容,先添加文本
1047
+ if content:
1048
+ parts.append({"text": content})
1049
+
1050
+ # 添加每个工具调用
1051
+ for tool_call in tool_calls:
1052
+ try:
1053
+ args = (
1054
+ json.loads(tool_call["function"]["arguments"])
1055
+ if isinstance(tool_call["function"]["arguments"], str)
1056
+ else tool_call["function"]["arguments"]
1057
+ )
1058
+
1059
+ # 根据工具的 schema 修正参数类型
1060
+ func_name = tool_call["function"]["name"]
1061
+ if func_name in tool_schemas:
1062
+ args = fix_tool_call_args_types(args, tool_schemas[func_name])
1063
+
1064
+ # 解码工具ID和thoughtSignature
1065
+ encoded_id = tool_call.get("id", "")
1066
+ original_id, signature = decode_tool_id_and_signature(encoded_id)
1067
+
1068
+ # 构建functionCall part
1069
+ function_call_part = {
1070
+ "functionCall": {
1071
+ "id": original_id,
1072
+ "name": func_name,
1073
+ "args": args
1074
+ }
1075
+ }
1076
+
1077
+ # 如果有thoughtSignature则添加,否则使用占位符以满足 Gemini API 要求
1078
+ if signature:
1079
+ function_call_part["thoughtSignature"] = signature
1080
+ else:
1081
+ function_call_part["thoughtSignature"] = "skip_thought_signature_validator"
1082
+
1083
+ parts.append(function_call_part)
1084
+ except (json.JSONDecodeError, KeyError) as e:
1085
+ log.error(f"Failed to parse tool call: {e}")
1086
+ continue
1087
+
1088
+ if parts:
1089
+ contents.append({"role": role, "parts": parts})
1090
+ continue
1091
+
1092
+ # 处理普通内容
1093
+ if isinstance(content, list):
1094
+ parts = []
1095
+ for part in content:
1096
+ if part.get("type") == "text":
1097
+ parts.append({"text": part.get("text", "")})
1098
+ elif part.get("type") == "image_url":
1099
+ image_url = part.get("image_url", {}).get("url")
1100
+ if image_url:
1101
+ try:
1102
+ mime_type, base64_data = image_url.split(";")
1103
+ _, mime_type = mime_type.split(":")
1104
+ _, base64_data = base64_data.split(",")
1105
+ parts.append({
1106
+ "inlineData": {
1107
+ "mimeType": mime_type,
1108
+ "data": base64_data,
1109
+ }
1110
+ })
1111
+ except ValueError:
1112
+ continue
1113
+ if parts:
1114
+ contents.append({"role": role, "parts": parts})
1115
+ elif content:
1116
+ contents.append({"role": role, "parts": [{"text": content}]})
1117
+
1118
+ # 循环结束后,flush 剩余的 tool parts(如果消息列表以 tool 消息结尾)
1119
+ flush_pending_tool_parts()
1120
+
1121
+ # 构建生成配置
1122
+ generation_config = {}
1123
+ model = openai_request.get("model", "")
1124
+
1125
+ # 基础参数映射
1126
+ if "temperature" in openai_request:
1127
+ generation_config["temperature"] = openai_request["temperature"]
1128
+ if "top_p" in openai_request:
1129
+ generation_config["topP"] = openai_request["top_p"]
1130
+ if "top_k" in openai_request:
1131
+ generation_config["topK"] = openai_request["top_k"]
1132
+ if "max_tokens" in openai_request or "max_completion_tokens" in openai_request:
1133
+ # max_completion_tokens 优先于 max_tokens
1134
+ max_tokens = openai_request.get("max_completion_tokens") or openai_request.get("max_tokens")
1135
+ generation_config["maxOutputTokens"] = max_tokens
1136
+ if "stop" in openai_request:
1137
+ stop = openai_request["stop"]
1138
+ generation_config["stopSequences"] = [stop] if isinstance(stop, str) else stop
1139
+ if "frequency_penalty" in openai_request:
1140
+ generation_config["frequencyPenalty"] = openai_request["frequency_penalty"]
1141
+ if "presence_penalty" in openai_request:
1142
+ generation_config["presencePenalty"] = openai_request["presence_penalty"]
1143
+ if "n" in openai_request:
1144
+ generation_config["candidateCount"] = openai_request["n"]
1145
+ if "seed" in openai_request:
1146
+ generation_config["seed"] = openai_request["seed"]
1147
+
1148
+ # 处理 response_format
1149
+ if "response_format" in openai_request and openai_request["response_format"]:
1150
+ response_format = openai_request["response_format"]
1151
+ format_type = response_format.get("type")
1152
+
1153
+ if format_type == "json_schema":
1154
+ # JSON Schema 模式
1155
+ if "json_schema" in response_format and "schema" in response_format["json_schema"]:
1156
+ schema = response_format["json_schema"]["schema"]
1157
+ # 清理 schema
1158
+ generation_config["responseSchema"] = _clean_schema_for_gemini(schema)
1159
+ generation_config["responseMimeType"] = "application/json"
1160
+ elif format_type == "json_object":
1161
+ # JSON Object 模式
1162
+ generation_config["responseMimeType"] = "application/json"
1163
+ elif format_type == "text":
1164
+ # Text 模式
1165
+ generation_config["responseMimeType"] = "text/plain"
1166
+
1167
+ # 如果contents为空,添加默认用户消息
1168
+ if not contents:
1169
+ contents.append({"role": "user", "parts": [{"text": "请根据系统指令回答。"}]})
1170
+
1171
+ # 构建基础请求
1172
+ gemini_request = {
1173
+ "contents": contents,
1174
+ "generationConfig": generation_config
1175
+ }
1176
+
1177
+ # 如果 merge_system_messages 已经添加了 systemInstruction,使用它
1178
+ if "systemInstruction" in openai_request:
1179
+ gemini_request["systemInstruction"] = openai_request["systemInstruction"]
1180
+
1181
+ # 处理工具 - 传递 model 参数以便根据模型类型选择清理策略
1182
+ model = openai_request.get("model", "")
1183
+ if "tools" in openai_request and openai_request["tools"]:
1184
+ gemini_request["tools"] = convert_openai_tools_to_gemini(openai_request["tools"], model)
1185
+
1186
+ # 处理tool_choice
1187
+ if "tool_choice" in openai_request and openai_request["tool_choice"]:
1188
+ gemini_request["toolConfig"] = convert_tool_choice_to_tool_config(openai_request["tool_choice"])
1189
+
1190
+ return gemini_request
1191
+
1192
+
1193
+ def convert_gemini_to_openai_response(
1194
+ gemini_response: Union[Dict[str, Any], Any],
1195
+ model: str,
1196
+ status_code: int = 200
1197
+ ) -> Dict[str, Any]:
1198
+ """
1199
+ 将 Gemini 格式非流式响应转换为 OpenAI 格式非流式响应
1200
+
1201
+ 注意: 如果收到的不是 200 开头的响应,不做任何处理,直接转发原始响应
1202
+
1203
+ Args:
1204
+ gemini_response: Gemini 格式的响应体 (字典或响应对象)
1205
+ model: 模型名称
1206
+ status_code: HTTP 状态码 (默认 200)
1207
+
1208
+ Returns:
1209
+ OpenAI 格式的响应体字典,或原始响应 (如果状态码不是 2xx)
1210
+ """
1211
+ # 非 2xx 状态码直���返回原始响应
1212
+ if not (200 <= status_code < 300):
1213
+ if isinstance(gemini_response, dict):
1214
+ return gemini_response
1215
+ else:
1216
+ # 如果是响应对象,尝试解析为字典
1217
+ try:
1218
+ if hasattr(gemini_response, "json"):
1219
+ return gemini_response.json()
1220
+ elif hasattr(gemini_response, "body"):
1221
+ body = gemini_response.body
1222
+ if isinstance(body, bytes):
1223
+ return json.loads(body.decode())
1224
+ return json.loads(str(body))
1225
+ else:
1226
+ return {"error": str(gemini_response)}
1227
+ except Exception:
1228
+ return {"error": str(gemini_response)}
1229
+
1230
+ # 确保是字典格式
1231
+ if not isinstance(gemini_response, dict):
1232
+ try:
1233
+ if hasattr(gemini_response, "json"):
1234
+ gemini_response = gemini_response.json()
1235
+ elif hasattr(gemini_response, "body"):
1236
+ body = gemini_response.body
1237
+ if isinstance(body, bytes):
1238
+ gemini_response = json.loads(body.decode())
1239
+ else:
1240
+ gemini_response = json.loads(str(body))
1241
+ else:
1242
+ gemini_response = json.loads(str(gemini_response))
1243
+ except Exception:
1244
+ return {"error": "Invalid response format"}
1245
+
1246
+ # 处理 GeminiCLI 的 response 包装格式
1247
+ if "response" in gemini_response:
1248
+ gemini_response = gemini_response["response"]
1249
+
1250
+ # 转换为 OpenAI 格式
1251
+ choices = []
1252
+
1253
+ for candidate in gemini_response.get("candidates", []):
1254
+ role = candidate.get("content", {}).get("role", "assistant")
1255
+
1256
+ # 将Gemini角色映射回OpenAI角色
1257
+ if role == "model":
1258
+ role = "assistant"
1259
+
1260
+ # 提取并分离thinking tokens和常规内容
1261
+ parts = candidate.get("content", {}).get("parts", [])
1262
+
1263
+ # 提取工具调用和文本内容
1264
+ tool_calls, text_content = extract_tool_calls_from_parts(parts)
1265
+
1266
+ # 提取多种类型的内容
1267
+ content_parts = []
1268
+ reasoning_parts = []
1269
+
1270
+ for part in parts:
1271
+ # 处理 executableCode(代码生成)
1272
+ if "executableCode" in part:
1273
+ exec_code = part["executableCode"]
1274
+ lang = exec_code.get("language", "python").lower()
1275
+ code = exec_code.get("code", "")
1276
+ # 添加代码块(前后加换行符确保 Markdown 渲染正确)
1277
+ content_parts.append(f"\n```{lang}\n{code}\n```\n")
1278
+
1279
+ # 处理 codeExecutionResult(代码执行结果)
1280
+ elif "codeExecutionResult" in part:
1281
+ result = part["codeExecutionResult"]
1282
+ outcome = result.get("outcome")
1283
+ output = result.get("output", "")
1284
+
1285
+ if output:
1286
+ label = "output" if outcome == "OUTCOME_OK" else "error"
1287
+ content_parts.append(f"\n```{label}\n{output}\n```\n")
1288
+
1289
+ # 处理 thought(思考内容)
1290
+ elif part.get("thought", False) and "text" in part:
1291
+ reasoning_parts.append(part["text"])
1292
+
1293
+ # 处理普通文本(非思考内容)
1294
+ elif "text" in part and not part.get("thought", False):
1295
+ # 这部分已经在 extract_tool_calls_from_parts 中处理
1296
+ pass
1297
+
1298
+ # 处理 inlineData(图片)
1299
+ elif "inlineData" in part:
1300
+ inline_data = part["inlineData"]
1301
+ mime_type = inline_data.get("mimeType", "image/png")
1302
+ base64_data = inline_data.get("data", "")
1303
+ # 使用 Markdown 格式
1304
+ content_parts.append(f"![gemini-generated-content](data:{mime_type};base64,{base64_data})")
1305
+
1306
+ # 合并所有内容部分
1307
+ if content_parts:
1308
+ # 使用双换行符连接各部分,确保块之间有间距
1309
+ additional_content = "\n\n".join(content_parts)
1310
+ if text_content:
1311
+ text_content = text_content + "\n\n" + additional_content
1312
+ else:
1313
+ text_content = additional_content
1314
+
1315
+ # 合并 reasoning content
1316
+ reasoning_content = "\n\n".join(reasoning_parts) if reasoning_parts else ""
1317
+
1318
+ # 构建消息对象
1319
+ message = {"role": role}
1320
+
1321
+ # 获取 Gemini 的 finishReason
1322
+ gemini_finish_reason = candidate.get("finishReason")
1323
+
1324
+ # 如果有工具调用
1325
+ if tool_calls:
1326
+ message["tool_calls"] = tool_calls
1327
+ message["content"] = text_content if text_content else None
1328
+ # 只有在正常停止(STOP)时才设为 tool_calls,其他情况保持原始 finish_reason
1329
+ # 这样可以避免在 SAFETY、MAX_TOKENS 等情况下仍然返回 tool_calls 导致循环
1330
+ if gemini_finish_reason == "STOP":
1331
+ finish_reason = "tool_calls"
1332
+ else:
1333
+ finish_reason = _map_finish_reason(gemini_finish_reason)
1334
+ else:
1335
+ message["content"] = text_content
1336
+ finish_reason = _map_finish_reason(gemini_finish_reason)
1337
+
1338
+ # 添加 reasoning content (如果有)
1339
+ if reasoning_content:
1340
+ message["reasoning_content"] = reasoning_content
1341
+
1342
+ choices.append({
1343
+ "index": candidate.get("index", 0),
1344
+ "message": message,
1345
+ "finish_reason": finish_reason,
1346
+ })
1347
+
1348
+ # 转换 usageMetadata
1349
+ usage = _convert_usage_metadata(gemini_response.get("usageMetadata"))
1350
+
1351
+ response_data = {
1352
+ "id": str(uuid.uuid4()),
1353
+ "object": "chat.completion",
1354
+ "created": int(time.time()),
1355
+ "model": model,
1356
+ "choices": choices,
1357
+ }
1358
+
1359
+ if usage:
1360
+ response_data["usage"] = usage
1361
+
1362
+ return response_data
1363
+
1364
+
1365
+ def convert_gemini_to_openai_stream(
1366
+ gemini_stream_chunk: str,
1367
+ model: str,
1368
+ response_id: str,
1369
+ status_code: int = 200
1370
+ ) -> Optional[str]:
1371
+ """
1372
+ 将 Gemini 格式流式响应块转换为 OpenAI SSE 格式流式响应
1373
+
1374
+ 注意: 如果收到的不是 200 开头的响应,不做任何处理,直接转发原始内容
1375
+
1376
+ Args:
1377
+ gemini_stream_chunk: Gemini 格式的流式响应块 (字符串,通常是 "data: {json}" 格式)
1378
+ model: 模型名称
1379
+ response_id: 此流式响应的一致ID
1380
+ status_code: HTTP 状态码 (默认 200)
1381
+
1382
+ Returns:
1383
+ OpenAI SSE 格式的响应字符串 (如 "data: {json}\n\n"),
1384
+ 或原始内容 (如果状态码不是 2xx),
1385
+ 或 None (如果解析失败)
1386
+ """
1387
+ # 非 2xx 状态码直接返回原始内容
1388
+ if not (200 <= status_code < 300):
1389
+ return gemini_stream_chunk
1390
+
1391
+ # 解析 Gemini 流式块
1392
+ try:
1393
+ # 去除 "data: " 前缀
1394
+ if isinstance(gemini_stream_chunk, bytes):
1395
+ if gemini_stream_chunk.startswith(b"data: "):
1396
+ payload_str = gemini_stream_chunk[len(b"data: "):].strip().decode("utf-8")
1397
+ else:
1398
+ payload_str = gemini_stream_chunk.strip().decode("utf-8")
1399
+ else:
1400
+ if gemini_stream_chunk.startswith("data: "):
1401
+ payload_str = gemini_stream_chunk[len("data: "):].strip()
1402
+ else:
1403
+ payload_str = gemini_stream_chunk.strip()
1404
+
1405
+ # 跳过空块
1406
+ if not payload_str:
1407
+ return None
1408
+
1409
+ # 解析 JSON
1410
+ gemini_chunk = json.loads(payload_str)
1411
+ except (json.JSONDecodeError, UnicodeDecodeError):
1412
+ # 解析失败,跳过此块
1413
+ return None
1414
+
1415
+ # 处理 GeminiCLI 的 response 包装格式
1416
+ if "response" in gemini_chunk:
1417
+ gemini_response = gemini_chunk["response"]
1418
+ else:
1419
+ gemini_response = gemini_chunk
1420
+
1421
+ # 转换为 OpenAI 流式格式
1422
+ choices = []
1423
+
1424
+ for candidate in gemini_response.get("candidates", []):
1425
+ role = candidate.get("content", {}).get("role", "assistant")
1426
+
1427
+ # 将Gemini角色映射回OpenAI角色
1428
+ if role == "model":
1429
+ role = "assistant"
1430
+
1431
+ # 提取并分离thinking tokens和常规内容
1432
+ parts = candidate.get("content", {}).get("parts", [])
1433
+
1434
+ # 提取工具调用和文本内容 (流式需要 index)
1435
+ tool_calls, text_content = extract_tool_calls_from_parts(parts, is_streaming=True)
1436
+
1437
+ # 提取多种类型的内容
1438
+ content_parts = []
1439
+ reasoning_parts = []
1440
+
1441
+ for part in parts:
1442
+ # 处理 executableCode(代码生成)
1443
+ if "executableCode" in part:
1444
+ exec_code = part["executableCode"]
1445
+ lang = exec_code.get("language", "python").lower()
1446
+ code = exec_code.get("code", "")
1447
+ content_parts.append(f"\n```{lang}\n{code}\n```\n")
1448
+
1449
+ # 处理 codeExecutionResult(代码执行结果)
1450
+ elif "codeExecutionResult" in part:
1451
+ result = part["codeExecutionResult"]
1452
+ outcome = result.get("outcome")
1453
+ output = result.get("output", "")
1454
+
1455
+ if output:
1456
+ label = "output" if outcome == "OUTCOME_OK" else "error"
1457
+ content_parts.append(f"\n```{label}\n{output}\n```\n")
1458
+
1459
+ # 处理 thought(思考内容)
1460
+ elif part.get("thought", False) and "text" in part:
1461
+ reasoning_parts.append(part["text"])
1462
+
1463
+ # 处理普通文本(非思考内容)
1464
+ elif "text" in part and not part.get("thought", False):
1465
+ # 这部分已经在 extract_tool_calls_from_parts 中处理
1466
+ pass
1467
+
1468
+ # 处理 inlineData(图片)
1469
+ elif "inlineData" in part:
1470
+ inline_data = part["inlineData"]
1471
+ mime_type = inline_data.get("mimeType", "image/png")
1472
+ base64_data = inline_data.get("data", "")
1473
+ content_parts.append(f"![gemini-generated-content](data:{mime_type};base64,{base64_data})")
1474
+
1475
+ # 合并所有内容部分
1476
+ if content_parts:
1477
+ additional_content = "\n\n".join(content_parts)
1478
+ if text_content:
1479
+ text_content = text_content + "\n\n" + additional_content
1480
+ else:
1481
+ text_content = additional_content
1482
+
1483
+ # 合并 reasoning content
1484
+ reasoning_content = "\n\n".join(reasoning_parts) if reasoning_parts else ""
1485
+
1486
+ # 构建 delta 对象
1487
+ delta = {}
1488
+
1489
+ if tool_calls:
1490
+ delta["tool_calls"] = tool_calls
1491
+ if text_content:
1492
+ delta["content"] = text_content
1493
+ elif text_content:
1494
+ delta["content"] = text_content
1495
+
1496
+ if reasoning_content:
1497
+ delta["reasoning_content"] = reasoning_content
1498
+
1499
+ # 获取 Gemini 的 finishReason
1500
+ gemini_finish_reason = candidate.get("finishReason")
1501
+ finish_reason = _map_finish_reason(gemini_finish_reason)
1502
+
1503
+ # 只有在正常停止(STOP)且有工具调用时才设为 tool_calls
1504
+ # 避免在 SAFETY、MAX_TOKENS 等情况下仍然返回 tool_calls 导致循环
1505
+ if tool_calls and gemini_finish_reason == "STOP":
1506
+ finish_reason = "tool_calls"
1507
+
1508
+ choices.append({
1509
+ "index": candidate.get("index", 0),
1510
+ "delta": delta,
1511
+ "finish_reason": finish_reason,
1512
+ })
1513
+
1514
+ # 转换 usageMetadata (只在流结束时存在)
1515
+ usage = _convert_usage_metadata(gemini_response.get("usageMetadata"))
1516
+
1517
+ # 构建 OpenAI 流式响应
1518
+ response_data = {
1519
+ "id": response_id,
1520
+ "object": "chat.completion.chunk",
1521
+ "created": int(time.time()),
1522
+ "model": model,
1523
+ "choices": choices,
1524
+ }
1525
+
1526
+ # 只在有 usage 数据且有 finish_reason 时添加 usage
1527
+ if usage:
1528
+ has_finish_reason = any(choice.get("finish_reason") for choice in choices)
1529
+ if has_finish_reason:
1530
+ response_data["usage"] = usage
1531
+
1532
+ # 转换为 SSE 格式: "data: {json}\n\n"
1533
+ return f"data: {json.dumps(response_data)}\n\n"
src/converter/thoughtSignature_fix.py ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ thoughtSignature 处理公共模块
3
+
4
+ 提供统一的 thoughtSignature 编码/解码功能,用于在工具调用ID中保留签名信息。
5
+ 这使得签名能够在客户端往返传输中保留,即使客户端会删除自定义字段。
6
+ """
7
+
8
+ from typing import Optional, Tuple
9
+
10
+ # 在工具调用ID中嵌入thoughtSignature的分隔符
11
+ # 这使得签名能够在客户端往返传输中保留,即使客户端会删除自定义字段
12
+ THOUGHT_SIGNATURE_SEPARATOR = "__thought__"
13
+
14
+
15
+ def encode_tool_id_with_signature(tool_id: str, signature: Optional[str]) -> str:
16
+ """
17
+ 将 thoughtSignature 编码到工具调用ID中,以便往返保留。
18
+
19
+ Args:
20
+ tool_id: 原始工具调用ID
21
+ signature: thoughtSignature(可选)
22
+
23
+ Returns:
24
+ 编码后的工具调用ID
25
+
26
+ Examples:
27
+ >>> encode_tool_id_with_signature("call_123", "abc")
28
+ 'call_123__thought__abc'
29
+ >>> encode_tool_id_with_signature("call_123", None)
30
+ 'call_123'
31
+ """
32
+ if not signature:
33
+ return tool_id
34
+ return f"{tool_id}{THOUGHT_SIGNATURE_SEPARATOR}{signature}"
35
+
36
+
37
+ def decode_tool_id_and_signature(encoded_id: str) -> Tuple[str, Optional[str]]:
38
+ """
39
+ 从编码的ID中提取原始工具ID和thoughtSignature。
40
+
41
+ Args:
42
+ encoded_id: 编码的工具调用ID
43
+
44
+ Returns:
45
+ (原始工具ID, thoughtSignature) 元组
46
+
47
+ Examples:
48
+ >>> decode_tool_id_and_signature("call_123__thought__abc")
49
+ ('call_123', 'abc')
50
+ >>> decode_tool_id_and_signature("call_123")
51
+ ('call_123', None)
52
+ """
53
+ if not encoded_id or THOUGHT_SIGNATURE_SEPARATOR not in encoded_id:
54
+ return encoded_id, None
55
+ parts = encoded_id.split(THOUGHT_SIGNATURE_SEPARATOR, 1)
56
+ return parts[0], parts[1] if len(parts) == 2 else None
src/converter/utils.py ADDED
@@ -0,0 +1,237 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Any, Dict
2
+
3
+
4
+ def extract_content_and_reasoning(parts: list) -> tuple:
5
+ """从Gemini响应部件中提取内容和推理内容
6
+
7
+ Args:
8
+ parts: Gemini 响应中的 parts 列表
9
+
10
+ Returns:
11
+ (content, reasoning_content, images): 文本内容、推理内容和图片数据的元组
12
+ - content: 文本内容字符串
13
+ - reasoning_content: 推理内容字符串
14
+ - images: 图片数据列表,每个元素格式为:
15
+ {
16
+ "type": "image_url",
17
+ "image_url": {
18
+ "url": "data:{mime_type};base64,{base64_data}"
19
+ }
20
+ }
21
+ """
22
+ content = ""
23
+ reasoning_content = ""
24
+ images = []
25
+
26
+ for part in parts:
27
+ # 提取文本内容
28
+ text = part.get("text", "")
29
+ if text:
30
+ if part.get("thought", False):
31
+ reasoning_content += text
32
+ else:
33
+ content += text
34
+
35
+ # 提取图片数据
36
+ if "inlineData" in part:
37
+ inline_data = part["inlineData"]
38
+ mime_type = inline_data.get("mimeType", "image/png")
39
+ base64_data = inline_data.get("data", "")
40
+ images.append({
41
+ "type": "image_url",
42
+ "image_url": {
43
+ "url": f"data:{mime_type};base64,{base64_data}"
44
+ }
45
+ })
46
+
47
+ return content, reasoning_content, images
48
+
49
+
50
+ async def merge_system_messages(request_body: Dict[str, Any]) -> Dict[str, Any]:
51
+ """
52
+ 根据兼容性模式处理请求体中的system消息
53
+
54
+ - 兼容性模式关闭(False):将连续的system消息合并为systemInstruction
55
+ - 兼容性模式开启(True):将所有system消息转换为user消息
56
+
57
+ Args:
58
+ request_body: OpenAI或Claude格式的请求体,包含messages字段
59
+
60
+ Returns:
61
+ 处理后的请求体
62
+
63
+ Example (兼容性模式关闭):
64
+ 输入:
65
+ {
66
+ "messages": [
67
+ {"role": "system", "content": "You are a helpful assistant."},
68
+ {"role": "system", "content": "You are an expert in Python."},
69
+ {"role": "user", "content": "Hello"}
70
+ ]
71
+ }
72
+
73
+ 输出:
74
+ {
75
+ "systemInstruction": {
76
+ "parts": [
77
+ {"text": "You are a helpful assistant."},
78
+ {"text": "You are an expert in Python."}
79
+ ]
80
+ },
81
+ "messages": [
82
+ {"role": "user", "content": "Hello"}
83
+ ]
84
+ }
85
+
86
+ Example (兼容性模式开启):
87
+ 输入:
88
+ {
89
+ "messages": [
90
+ {"role": "system", "content": "You are a helpful assistant."},
91
+ {"role": "user", "content": "Hello"}
92
+ ]
93
+ }
94
+
95
+ 输出:
96
+ {
97
+ "messages": [
98
+ {"role": "user", "content": "You are a helpful assistant."},
99
+ {"role": "user", "content": "Hello"}
100
+ ]
101
+ }
102
+
103
+ Example (Anthropic格式,兼容性模式关闭):
104
+ 输入:
105
+ {
106
+ "system": "You are a helpful assistant.",
107
+ "messages": [
108
+ {"role": "user", "content": "Hello"}
109
+ ]
110
+ }
111
+
112
+ 输出:
113
+ {
114
+ "systemInstruction": {
115
+ "parts": [
116
+ {"text": "You are a helpful assistant."}
117
+ ]
118
+ },
119
+ "messages": [
120
+ {"role": "user", "content": "Hello"}
121
+ ]
122
+ }
123
+ """
124
+ from config import get_compatibility_mode_enabled
125
+
126
+ compatibility_mode = await get_compatibility_mode_enabled()
127
+
128
+ # 处理 Anthropic 格式的顶层 system 参数
129
+ # Anthropic API 规范: system 是顶层参数,不在 messages 中
130
+ system_content = request_body.get("system")
131
+ if system_content:
132
+ system_parts = []
133
+
134
+ if isinstance(system_content, str):
135
+ if system_content.strip():
136
+ system_parts.append({"text": system_content})
137
+ elif isinstance(system_content, list):
138
+ # system 可以是包含多个块的列表
139
+ for item in system_content:
140
+ if isinstance(item, dict):
141
+ if item.get("type") == "text" and item.get("text", "").strip():
142
+ system_parts.append({"text": item["text"]})
143
+ elif isinstance(item, str) and item.strip():
144
+ system_parts.append({"text": item})
145
+
146
+ if system_parts:
147
+ if compatibility_mode:
148
+ # 兼容性模式:将 system 转换为 user 消息插入到 messages 开头
149
+ user_system_message = {
150
+ "role": "user",
151
+ "content": system_content if isinstance(system_content, str) else
152
+ "\n".join(part["text"] for part in system_parts)
153
+ }
154
+ messages = request_body.get("messages", [])
155
+ request_body = request_body.copy()
156
+ request_body["messages"] = [user_system_message] + messages
157
+ else:
158
+ # 非兼容性模式:添加为 systemInstruction
159
+ request_body = request_body.copy()
160
+ request_body["systemInstruction"] = {"parts": system_parts}
161
+
162
+ messages = request_body.get("messages", [])
163
+ if not messages:
164
+ return request_body
165
+
166
+ compatibility_mode = await get_compatibility_mode_enabled()
167
+
168
+ if compatibility_mode:
169
+ # 兼容性模式开启:将所有system消息转换为user消息
170
+ converted_messages = []
171
+ for message in messages:
172
+ if message.get("role") == "system":
173
+ # 创建新的消息对象,将role改为user
174
+ converted_message = message.copy()
175
+ converted_message["role"] = "user"
176
+ converted_messages.append(converted_message)
177
+ else:
178
+ converted_messages.append(message)
179
+
180
+ result = request_body.copy()
181
+ result["messages"] = converted_messages
182
+ return result
183
+ else:
184
+ # 兼容性模式关闭:提取连续的system消息合并为systemInstruction
185
+ system_parts = []
186
+
187
+ # 如果已经从顶层 system 参数创建了 systemInstruction,获取现有的 parts
188
+ if "systemInstruction" in request_body:
189
+ existing_instruction = request_body.get("systemInstruction", {})
190
+ if isinstance(existing_instruction, dict):
191
+ system_parts = existing_instruction.get("parts", []).copy()
192
+
193
+ remaining_messages = []
194
+ collecting_system = True
195
+
196
+ for message in messages:
197
+ role = message.get("role", "")
198
+ content = message.get("content", "")
199
+
200
+ if role == "system" and collecting_system:
201
+ # 提取system消息的文本内容
202
+ if isinstance(content, str):
203
+ if content.strip():
204
+ system_parts.append({"text": content})
205
+ elif isinstance(content, list):
206
+ # 处理列表格式的content
207
+ for item in content:
208
+ if isinstance(item, dict):
209
+ if item.get("type") == "text" and item.get("text", "").strip():
210
+ system_parts.append({"text": item["text"]})
211
+ elif isinstance(item, str) and item.strip():
212
+ system_parts.append({"text": item})
213
+ else:
214
+ # 遇到非system消息,停止收集
215
+ collecting_system = False
216
+ if role == "system":
217
+ # 将后续的system消息转换为user消息
218
+ converted_message = message.copy()
219
+ converted_message["role"] = "user"
220
+ remaining_messages.append(converted_message)
221
+ else:
222
+ remaining_messages.append(message)
223
+
224
+ # 如果没有找到任何system消息(包括顶层参数和messages中的),返回原始请求体
225
+ if not system_parts:
226
+ return request_body
227
+
228
+ # 构建新的请求体
229
+ result = request_body.copy()
230
+
231
+ # 添加或更新systemInstruction
232
+ result["systemInstruction"] = {"parts": system_parts}
233
+
234
+ # 更新messages列表(移除已处理的system消息)
235
+ result["messages"] = remaining_messages
236
+
237
+ return result
src/credential_manager.py ADDED
@@ -0,0 +1,510 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 凭证管理器
3
+ """
4
+
5
+ import asyncio
6
+ import time
7
+ from datetime import datetime, timezone
8
+ from typing import Any, Dict, List, Optional, Tuple
9
+
10
+ from log import log
11
+
12
+ from src.google_oauth_api import Credentials
13
+ from src.storage_adapter import get_storage_adapter
14
+
15
+ class CredentialManager:
16
+ """
17
+ 统一凭证管理器
18
+ 所有存储操作通过storage_adapter进行
19
+ """
20
+
21
+ def __init__(self):
22
+ # 核心状态
23
+ self._initialized = False
24
+ self._storage_adapter = None
25
+
26
+ # 并发控制(简化)
27
+ # 后端数据库自行处理并发,credential_manager 不再使用本地锁
28
+
29
+ async def _ensure_initialized(self):
30
+ """确保管理器已初始化(内部使用)"""
31
+ if not self._initialized or self._storage_adapter is None:
32
+ await self.initialize()
33
+
34
+ async def initialize(self):
35
+ """初始化凭证管理器"""
36
+ if self._initialized and self._storage_adapter is not None:
37
+ return
38
+
39
+ # 初始化统一存储适配器
40
+ self._storage_adapter = await get_storage_adapter()
41
+ self._initialized = True
42
+
43
+ async def close(self):
44
+ """清理资源"""
45
+ log.debug("Closing credential manager...")
46
+ self._initialized = False
47
+ log.debug("Credential manager closed")
48
+
49
+ async def get_valid_credential(
50
+ self, mode: str = "geminicli", model_name: Optional[str] = None
51
+ ) -> Optional[Tuple[str, Dict[str, Any]]]:
52
+ """
53
+ 获取有效的凭证 - 随机负载均衡版
54
+ 每次随机选择一个可用的凭证(未禁用、未冷却、符合preview要求)
55
+ 如果刷新失败会自动禁用失效凭证并重试获取下一个可用凭证
56
+
57
+ Args:
58
+ mode: 凭证模式 ("geminicli" 或 "antigravity")
59
+ model_name: 完整模型名,用于模型级冷却检查和preview筛选
60
+ - geminicli: 完整模型名
61
+ - 包含 "preview" 的模型只能使用 preview=True 的凭证
62
+ - 不包含 "preview" 的模型优先使用 preview=False 的凭证
63
+ - antigravity: 完整模型名(如 "gemini-2.0-flash-exp")
64
+ """
65
+ await self._ensure_initialized()
66
+
67
+ # 最多重试3次
68
+ max_retries = 3
69
+ for attempt in range(max_retries):
70
+ result = await self._storage_adapter._backend.get_next_available_credential(
71
+ mode=mode, model_name=model_name
72
+ )
73
+
74
+ # 如果没有可用凭证,直接返回None
75
+ if not result:
76
+ if attempt == 0:
77
+ log.warning(f"没有可用凭证 (mode={mode}, model_name={model_name})")
78
+ return None
79
+
80
+ filename, credential_data = result
81
+
82
+ # Token 刷新检查
83
+ if await self._should_refresh_token(credential_data):
84
+ log.debug(f"Token需要刷新 - 文件: {filename} (mode={mode})")
85
+ refreshed_data = await self._refresh_token(credential_data, filename, mode=mode)
86
+ if refreshed_data:
87
+ # 刷新成功,返回凭证
88
+ credential_data = refreshed_data
89
+ log.debug(f"Token刷新成功: {filename} (mode={mode})")
90
+ return filename, credential_data
91
+ else:
92
+ # 刷新失败(_refresh_token内部已自动禁用失效凭证)
93
+ log.warning(f"Token刷新失败,尝试获取下一个凭证: {filename} (mode={mode}, attempt={attempt+1}/{max_retries})")
94
+ # 继续循环,尝试获取下一个可用凭证
95
+ continue
96
+ else:
97
+ # Token有效,直接返回
98
+ return filename, credential_data
99
+
100
+ # 重试次数用尽
101
+ log.error(f"重试{max_retries}次后仍无可用凭证 (mode={mode}, model_name={model_name})")
102
+ return None
103
+
104
+ async def add_credential(self, credential_name: str, credential_data: Dict[str, Any]):
105
+ """
106
+ 新增或更新一个凭证
107
+ 存储层会自动处理轮换顺序
108
+ """
109
+ await self._ensure_initialized()
110
+ await self._storage_adapter.store_credential(credential_name, credential_data)
111
+ log.info(f"Credential added/updated: {credential_name}")
112
+
113
+ async def add_antigravity_credential(self, credential_name: str, credential_data: Dict[str, Any]):
114
+ """
115
+ 新增或更新一个Antigravity凭证
116
+ 存储层会自动处理轮换顺序
117
+ """
118
+ await self._ensure_initialized()
119
+ await self._storage_adapter.store_credential(credential_name, credential_data, mode="antigravity")
120
+ log.info(f"Antigravity credential added/updated: {credential_name}")
121
+
122
+ async def remove_credential(self, credential_name: str, mode: str = "geminicli") -> bool:
123
+ """删除一个凭证"""
124
+ await self._ensure_initialized()
125
+ try:
126
+ await self._storage_adapter.delete_credential(credential_name, mode=mode)
127
+ log.info(f"Credential removed: {credential_name} (mode={mode})")
128
+ return True
129
+ except Exception as e:
130
+ log.error(f"Error removing credential {credential_name}: {e}")
131
+ return False
132
+
133
+ async def update_credential_state(self, credential_name: str, state_updates: Dict[str, Any], mode: str = "geminicli"):
134
+ """更新凭证状态"""
135
+ log.debug(f"[CredMgr] update_credential_state 开始: credential_name={credential_name}, state_updates={state_updates}, mode={mode}")
136
+ log.debug(f"[CredMgr] 调用 _ensure_initialized...")
137
+ await self._ensure_initialized()
138
+ log.debug(f"[CredMgr] _ensure_initialized 完成")
139
+ try:
140
+ log.debug(f"[CredMgr] 调用 storage_adapter.update_credential_state...")
141
+ success = await self._storage_adapter.update_credential_state(
142
+ credential_name, state_updates, mode=mode
143
+ )
144
+ log.debug(f"[CredMgr] storage_adapter.update_credential_state 返回: {success}")
145
+ if success:
146
+ log.debug(f"Updated credential state: {credential_name} (mode={mode})")
147
+ else:
148
+ log.warning(f"Failed to update credential state: {credential_name} (mode={mode})")
149
+ return success
150
+ except Exception as e:
151
+ log.error(f"Error updating credential state {credential_name}: {e}")
152
+ return False
153
+
154
+ async def set_cred_disabled(self, credential_name: str, disabled: bool, mode: str = "geminicli"):
155
+ """设置凭证的启用/禁用状态"""
156
+ try:
157
+ log.info(f"[CredMgr] set_cred_disabled 开始: credential_name={credential_name}, disabled={disabled}, mode={mode}")
158
+ success = await self.update_credential_state(
159
+ credential_name, {"disabled": disabled}, mode=mode
160
+ )
161
+ log.info(f"[CredMgr] update_credential_state 返回: success={success}")
162
+ if success:
163
+ action = "disabled" if disabled else "enabled"
164
+ log.info(f"Credential {action}: {credential_name} (mode={mode})")
165
+ else:
166
+ log.warning(f"[CredMgr] 设置禁用状态失败: credential_name={credential_name}, disabled={disabled}")
167
+ return success
168
+ except Exception as e:
169
+ log.error(f"Error setting credential disabled state {credential_name}: {e}")
170
+ return False
171
+
172
+ async def get_creds_status(self) -> Dict[str, Dict[str, Any]]:
173
+ """获取所有凭证的状态"""
174
+ await self._ensure_initialized()
175
+ try:
176
+ return await self._storage_adapter.get_all_credential_states()
177
+ except Exception as e:
178
+ log.error(f"Error getting credential statuses: {e}")
179
+ return {}
180
+
181
+ async def get_creds_summary(self) -> List[Dict[str, Any]]:
182
+ """
183
+ 获取所有凭证的摘要信息(轻量级,不包含完整凭证数据)
184
+ 使用后端的高性能查询
185
+ """
186
+ await self._ensure_initialized()
187
+ try:
188
+ return await self._storage_adapter._backend.get_credentials_summary()
189
+ except Exception as e:
190
+ log.error(f"Error getting credentials summary: {e}")
191
+ return []
192
+
193
+ async def get_or_fetch_user_email(self, credential_name: str, mode: str = "geminicli") -> Optional[str]:
194
+ """获取或获取用户邮箱地址"""
195
+ try:
196
+ # 确保已初始化
197
+ await self._ensure_initialized()
198
+
199
+ # 从状态中获取缓存的邮箱
200
+ state = await self._storage_adapter.get_credential_state(credential_name, mode=mode)
201
+ cached_email = state.get("user_email") if state else None
202
+
203
+ if cached_email:
204
+ return cached_email
205
+
206
+ # 如果没有缓存,从凭证数据获取
207
+ credential_data = await self._storage_adapter.get_credential(credential_name, mode=mode)
208
+ if not credential_data:
209
+ return None
210
+
211
+ # 创建凭证对象并自动刷新 token
212
+ from .google_oauth_api import Credentials, get_user_email
213
+
214
+ credentials = Credentials.from_dict(credential_data)
215
+ if not credentials:
216
+ return None
217
+
218
+ # 自动刷新 token(如果需要)
219
+ token_refreshed = await credentials.refresh_if_needed()
220
+
221
+ # 如果 token 被刷新了,更新存储
222
+ if token_refreshed:
223
+ log.info(f"Token已自动刷新: {credential_name} (mode={mode})")
224
+ updated_data = credentials.to_dict()
225
+ await self._storage_adapter.store_credential(credential_name, updated_data, mode=mode)
226
+
227
+ # 获取邮箱
228
+ email = await get_user_email(credentials)
229
+
230
+ if email:
231
+ # 缓存邮箱地址
232
+ await self._storage_adapter.update_credential_state(
233
+ credential_name, {"user_email": email}, mode=mode
234
+ )
235
+ return email
236
+
237
+ return None
238
+
239
+ except Exception as e:
240
+ log.error(f"Error fetching user email for {credential_name}: {e}")
241
+ return None
242
+
243
+ async def record_api_call_result(
244
+ self,
245
+ credential_name: str,
246
+ success: bool,
247
+ error_code: Optional[int] = None,
248
+ cooldown_until: Optional[float] = None,
249
+ mode: str = "geminicli",
250
+ model_name: Optional[str] = None,
251
+ error_message: Optional[str] = None
252
+ ):
253
+ """
254
+ 记录API调用结果
255
+
256
+ Args:
257
+ credential_name: 凭证名称
258
+ success: 是否成功
259
+ error_code: 错误码(如果失败)
260
+ cooldown_until: 冷却截止时间戳(Unix时间戳,针对429 QUOTA_EXHAUSTED)
261
+ mode: 凭证模式 ("geminicli" 或 "antigravity")
262
+ model_name: 模型名(用于设置模型级冷却)
263
+ error_message: 错误信息(如果失败)
264
+ """
265
+ await self._ensure_initialized()
266
+ try:
267
+ if success:
268
+ # 条件写入:仅当凭证有错误状态或模型冷却时才写 DB,零内存缓存
269
+ # fire-and-forget,不阻塞请求链路
270
+ asyncio.create_task(
271
+ self._storage_adapter._backend.record_success(
272
+ credential_name, model_name=model_name, mode=mode
273
+ )
274
+ )
275
+
276
+ elif error_code:
277
+ # 记录错误码和错误信息
278
+ error_messages = {}
279
+ if error_message:
280
+ error_messages[str(error_code)] = error_message
281
+
282
+ state_updates = {
283
+ "error_codes": [error_code],
284
+ "error_messages": error_messages,
285
+ }
286
+
287
+ await self.update_credential_state(credential_name, state_updates, mode=mode)
288
+
289
+ # 设置模型级冷却
290
+ if cooldown_until is not None and model_name:
291
+ if hasattr(self._storage_adapter._backend, 'set_model_cooldown'):
292
+ await self._storage_adapter._backend.set_model_cooldown(
293
+ credential_name, model_name, cooldown_until, mode=mode
294
+ )
295
+ log.info(
296
+ f"设置模型级冷却: {credential_name}, model_name={model_name}, "
297
+ f"冷却至: {datetime.fromtimestamp(cooldown_until, timezone.utc).isoformat()}"
298
+ )
299
+
300
+ except Exception as e:
301
+ log.error(f"Error recording API call result for {credential_name}: {e}")
302
+
303
+ async def _should_refresh_token(self, credential_data: Dict[str, Any]) -> bool:
304
+ """检查token是否需要刷新"""
305
+ try:
306
+ # 如果没有access_token或过期时间,需要刷新
307
+ if not credential_data.get("access_token") and not credential_data.get("token"):
308
+ log.debug("没有access_token,需要刷新")
309
+ return True
310
+
311
+ expiry_str = credential_data.get("expiry")
312
+ if not expiry_str:
313
+ log.debug("没有过期时间,需要刷新")
314
+ return True
315
+
316
+ # 解析过期时间
317
+ try:
318
+ if isinstance(expiry_str, str):
319
+ if "+" in expiry_str:
320
+ file_expiry = datetime.fromisoformat(expiry_str)
321
+ elif expiry_str.endswith("Z"):
322
+ file_expiry = datetime.fromisoformat(expiry_str.replace("Z", "+00:00"))
323
+ else:
324
+ file_expiry = datetime.fromisoformat(expiry_str)
325
+ else:
326
+ log.debug("过期时间格式无效,需要刷新")
327
+ return True
328
+
329
+ # 确保时区信息
330
+ if file_expiry.tzinfo is None:
331
+ file_expiry = file_expiry.replace(tzinfo=timezone.utc)
332
+
333
+ # 检查是否还有至少5分钟有效期
334
+ now = datetime.now(timezone.utc)
335
+ time_left = (file_expiry - now).total_seconds()
336
+
337
+ log.debug(
338
+ f"Token时间检查: "
339
+ f"当前UTC时间={now.isoformat()}, "
340
+ f"过期时间={file_expiry.isoformat()}, "
341
+ f"剩余时间={int(time_left/60)}分{int(time_left%60)}秒"
342
+ )
343
+
344
+ if time_left > 300: # 5分钟缓冲
345
+ return False
346
+ else:
347
+ log.debug(f"Token即将过期(剩余{int(time_left/60)}分钟),需要刷新")
348
+ return True
349
+
350
+ except Exception as e:
351
+ log.warning(f"解析过期时间失败: {e},需要刷新")
352
+ return True
353
+
354
+ except Exception as e:
355
+ log.error(f"检查token过期时出错: {e}")
356
+ return True
357
+
358
+ async def _refresh_token(
359
+ self, credential_data: Dict[str, Any], filename: str, mode: str = "geminicli"
360
+ ) -> Optional[Dict[str, Any]]:
361
+ """刷新token并更新存储"""
362
+ await self._ensure_initialized()
363
+ try:
364
+ # 创建Credentials对象
365
+ creds = Credentials.from_dict(credential_data)
366
+
367
+ # 检查是否可以刷新
368
+ if not creds.refresh_token:
369
+ log.error(f"没有refresh_token,无法刷新: {filename} (mode={mode})")
370
+ # 自动禁用没有refresh_token的凭证
371
+ try:
372
+ await self.update_credential_state(filename, {"disabled": True}, mode=mode)
373
+ log.warning(f"凭证已自动禁用(缺少refresh_token): {filename}")
374
+ except Exception as e:
375
+ log.error(f"禁用凭证失败 {filename}: {e}")
376
+ return None
377
+
378
+ # 刷新token
379
+ log.debug(f"正在刷新token: {filename} (mode={mode})")
380
+ await creds.refresh()
381
+
382
+ # 更新凭证数据
383
+ if creds.access_token:
384
+ credential_data["access_token"] = creds.access_token
385
+ # 保持兼容性
386
+ credential_data["token"] = creds.access_token
387
+
388
+ if creds.expires_at:
389
+ credential_data["expiry"] = creds.expires_at.isoformat()
390
+
391
+ # 保存到存储
392
+ await self._storage_adapter.store_credential(filename, credential_data, mode=mode)
393
+ log.info(f"Token刷新成功并已保存: {filename} (mode={mode})")
394
+
395
+ return credential_data
396
+
397
+ except Exception as e:
398
+ error_msg = str(e)
399
+ log.error(f"Token刷新失败 {filename} (mode={mode}): {error_msg}")
400
+
401
+ # 尝试提取HTTP状态码(TokenError可能携带status_code属性)
402
+ status_code = None
403
+ if hasattr(e, 'status_code'):
404
+ status_code = e.status_code
405
+
406
+ # 检查是否是凭证永久失效的错误(只有明确的400/403等才判定为永久失效)
407
+ is_permanent_failure = self._is_permanent_refresh_failure(error_msg, status_code)
408
+
409
+ if is_permanent_failure:
410
+ log.warning(f"检测到凭证永久失效 (HTTP {status_code}): {filename}")
411
+ # 记录失效状态
412
+ if status_code:
413
+ await self.record_api_call_result(filename, False, status_code, mode=mode)
414
+ else:
415
+ await self.record_api_call_result(filename, False, 400, mode=mode)
416
+
417
+ # 禁用失效凭证
418
+ try:
419
+ # 直接禁用该凭证(随机选择机制会自动跳过它)
420
+ disabled_ok = await self.update_credential_state(filename, {"disabled": True}, mode=mode)
421
+ if disabled_ok:
422
+ log.warning(f"永久失效凭证已禁用: {filename}")
423
+ else:
424
+ log.warning("永久失效凭证禁用失败,将由上层逻辑继续处理")
425
+ except Exception as e2:
426
+ log.error(f"禁用永久失效凭证时出错 {filename}: {e2}")
427
+ else:
428
+ # 网络错误或其他临时性错误,不封禁凭证
429
+ log.warning(f"Token刷新失败但非永久性错误 (HTTP {status_code}),不封禁凭证: {filename}")
430
+
431
+ return None
432
+
433
+ def _is_permanent_refresh_failure(self, error_msg: str, status_code: Optional[int] = None) -> bool:
434
+ """
435
+ 判断是否是凭证永久失效的错误
436
+
437
+ Args:
438
+ error_msg: 错误信息
439
+ status_code: HTTP状态码(如果有)
440
+
441
+ Returns:
442
+ True表示凭证永久失效应封禁,False表示临时错误不应封禁
443
+ """
444
+ # 优先使用HTTP状态码判断
445
+ if status_code is not None:
446
+ # 400/401/403 明确表示凭证有问题,应该封禁
447
+ if status_code in [400, 401, 403]:
448
+ log.debug(f"检测到客户端错误状态码 {status_code},判定为永久失效")
449
+ return True
450
+ # 500/502/503/504 是服务器错误,不应封禁凭证
451
+ elif status_code in [500, 502, 503, 504]:
452
+ log.debug(f"检测到服务器错误状态码 {status_code},不应封禁凭证")
453
+ return False
454
+ # 429 (限流) 不应封禁凭证
455
+ elif status_code == 429:
456
+ log.debug("检测到限流错误 429,不应封禁凭证")
457
+ return False
458
+
459
+ # 如果没有状态码,回退到错误信息匹配(谨慎判断)
460
+ # 只有明确的凭证失效错误才判定为永久失效
461
+ permanent_error_patterns = [
462
+ "invalid_grant",
463
+ "refresh_token_expired",
464
+ "invalid_refresh_token",
465
+ "unauthorized_client",
466
+ "access_denied",
467
+ ]
468
+
469
+ error_msg_lower = error_msg.lower()
470
+ for pattern in permanent_error_patterns:
471
+ if pattern.lower() in error_msg_lower:
472
+ log.debug(f"错误信息匹配到永久失效模式: {pattern}")
473
+ return True
474
+
475
+ # 默认认为是临时错误(如网络问题),不应封禁凭证
476
+ log.debug("未匹配到明确的永久失效模式,判定为临时错误")
477
+ return False
478
+
479
+ class _CredentialManagerSingleton:
480
+ """单例包装器,支持懒加载和自动初始化"""
481
+
482
+ _instance: Optional[CredentialManager] = None
483
+ _lock = None
484
+
485
+ def __init__(self):
486
+ self._manager = None
487
+
488
+ async def _get_or_create(self) -> CredentialManager:
489
+ """获取或创建单例实例(线程安全)"""
490
+ if self._instance is None:
491
+ # 简单的实例创建(异步环境下一般不需要复杂的锁)
492
+ if self._instance is None:
493
+ self._instance = CredentialManager()
494
+ await self._instance.initialize()
495
+ log.debug("CredentialManager singleton initialized")
496
+
497
+ return self._instance
498
+
499
+ def __getattr__(self, name):
500
+ """代理所有方法调用到真实的 CredentialManager 实例"""
501
+ async def _async_wrapper(*args, **kwargs):
502
+ manager = await self._get_or_create()
503
+ method = getattr(manager, name)
504
+ return await method(*args, **kwargs)
505
+
506
+ return _async_wrapper
507
+
508
+
509
+ # 全局单例实例 - 直接导入即可使用
510
+ credential_manager = _CredentialManagerSingleton()
src/google_oauth_api.py ADDED
@@ -0,0 +1,852 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Google OAuth2 认证模块
3
+ """
4
+
5
+ import time
6
+ import asyncio
7
+ from datetime import datetime, timedelta, timezone
8
+ from typing import Any, Dict, List, Optional, Tuple
9
+ from urllib.parse import urlencode
10
+
11
+ import jwt
12
+
13
+ from config import (
14
+ get_googleapis_proxy_url,
15
+ get_oauth_proxy_url,
16
+ get_resource_manager_api_url,
17
+ get_service_usage_api_url,
18
+ )
19
+ from log import log
20
+
21
+ from src.httpx_client import get_async, post_async
22
+
23
+
24
+ class TokenError(Exception):
25
+ """Token相关错误"""
26
+
27
+ pass
28
+
29
+
30
+ class Credentials:
31
+ """凭证类"""
32
+
33
+ def __init__(
34
+ self,
35
+ access_token: str,
36
+ refresh_token: str = None,
37
+ client_id: str = None,
38
+ client_secret: str = None,
39
+ expires_at: datetime = None,
40
+ project_id: str = None,
41
+ ):
42
+ self.access_token = access_token
43
+ self.refresh_token = refresh_token
44
+ self.client_id = client_id
45
+ self.client_secret = client_secret
46
+ self.expires_at = expires_at
47
+ self.project_id = project_id
48
+
49
+ # 反代配置将在使用时异步获取
50
+ self.oauth_base_url = None
51
+ self.token_endpoint = None
52
+
53
+ def is_expired(self) -> bool:
54
+ """检查token是否过期"""
55
+ if not self.expires_at:
56
+ return True
57
+
58
+ # 提前3分钟认为过期
59
+ buffer = timedelta(minutes=3)
60
+ return (self.expires_at - buffer) <= datetime.now(timezone.utc)
61
+
62
+ async def refresh_if_needed(self) -> bool:
63
+ """如果需要则刷新token"""
64
+ if not self.is_expired():
65
+ return False
66
+
67
+ if not self.refresh_token:
68
+ raise TokenError("需要刷新令牌但未提供")
69
+
70
+ await self.refresh()
71
+ return True
72
+
73
+ async def refresh(self):
74
+ """刷新访问令牌"""
75
+ if not self.refresh_token:
76
+ raise TokenError("无刷新令牌")
77
+
78
+ data = {
79
+ "client_id": self.client_id,
80
+ "client_secret": self.client_secret,
81
+ "refresh_token": self.refresh_token,
82
+ "grant_type": "refresh_token",
83
+ }
84
+
85
+ try:
86
+ oauth_base_url = await get_oauth_proxy_url()
87
+ token_url = f"{oauth_base_url.rstrip('/')}/token"
88
+ response = await post_async(
89
+ token_url,
90
+ data=data,
91
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
92
+ )
93
+ response.raise_for_status()
94
+
95
+ token_data = response.json()
96
+ self.access_token = token_data["access_token"]
97
+
98
+ if "expires_in" in token_data:
99
+ expires_in = int(token_data["expires_in"])
100
+ current_utc = datetime.now(timezone.utc)
101
+ self.expires_at = current_utc + timedelta(seconds=expires_in)
102
+ log.debug(
103
+ f"Token刷新: 当前UTC时间={current_utc.isoformat()}, "
104
+ f"有效期={expires_in}秒, "
105
+ f"过期时间={self.expires_at.isoformat()}"
106
+ )
107
+
108
+ if "refresh_token" in token_data:
109
+ self.refresh_token = token_data["refresh_token"]
110
+
111
+ log.debug(f"Token刷新成功,过期时间: {self.expires_at}")
112
+
113
+ except Exception as e:
114
+ error_msg = str(e)
115
+ status_code = None
116
+ if hasattr(e, 'response') and hasattr(e.response, 'status_code'):
117
+ status_code = e.response.status_code
118
+ error_msg = f"Token刷新失败 (HTTP {status_code}): {error_msg}"
119
+ else:
120
+ error_msg = f"Token刷新失败: {error_msg}"
121
+
122
+ log.error(error_msg)
123
+ token_error = TokenError(error_msg)
124
+ token_error.status_code = status_code
125
+ raise token_error
126
+
127
+ @classmethod
128
+ def from_dict(cls, data: Dict[str, Any]) -> "Credentials":
129
+ """从字典创建凭证"""
130
+ # 处理过期时间
131
+ expires_at = None
132
+ if "expiry" in data and data["expiry"]:
133
+ try:
134
+ expiry_str = data["expiry"]
135
+ if isinstance(expiry_str, str):
136
+ if expiry_str.endswith("Z"):
137
+ expires_at = datetime.fromisoformat(expiry_str.replace("Z", "+00:00"))
138
+ elif "+" in expiry_str:
139
+ expires_at = datetime.fromisoformat(expiry_str)
140
+ else:
141
+ expires_at = datetime.fromisoformat(expiry_str).replace(tzinfo=timezone.utc)
142
+ except ValueError:
143
+ log.warning(f"无法解析过期时间: {expiry_str}")
144
+
145
+ return cls(
146
+ access_token=data.get("token") or data.get("access_token", ""),
147
+ refresh_token=data.get("refresh_token"),
148
+ client_id=data.get("client_id"),
149
+ client_secret=data.get("client_secret"),
150
+ expires_at=expires_at,
151
+ project_id=data.get("project_id"),
152
+ )
153
+
154
+ def to_dict(self) -> Dict[str, Any]:
155
+ """转为字典"""
156
+ result = {
157
+ "access_token": self.access_token,
158
+ "refresh_token": self.refresh_token,
159
+ "client_id": self.client_id,
160
+ "client_secret": self.client_secret,
161
+ "project_id": self.project_id,
162
+ }
163
+
164
+ if self.expires_at:
165
+ result["expiry"] = self.expires_at.isoformat()
166
+
167
+ return result
168
+
169
+
170
+ class Flow:
171
+ """OAuth流程类"""
172
+
173
+ def __init__(
174
+ self, client_id: str, client_secret: str, scopes: List[str], redirect_uri: str = None
175
+ ):
176
+ self.client_id = client_id
177
+ self.client_secret = client_secret
178
+ self.scopes = scopes
179
+ self.redirect_uri = redirect_uri
180
+
181
+ # 反代配置将在使用时异步获取
182
+ self.oauth_base_url = None
183
+ self.token_endpoint = None
184
+ self.auth_endpoint = "https://accounts.google.com/o/oauth2/auth"
185
+
186
+ self.credentials: Optional[Credentials] = None
187
+
188
+ def get_auth_url(self, state: str = None, **kwargs) -> str:
189
+ """生成授权URL"""
190
+ params = {
191
+ "client_id": self.client_id,
192
+ "redirect_uri": self.redirect_uri,
193
+ "scope": " ".join(self.scopes),
194
+ "response_type": "code",
195
+ "access_type": "offline",
196
+ "prompt": "consent",
197
+ "include_granted_scopes": "true",
198
+ }
199
+
200
+ if state:
201
+ params["state"] = state
202
+
203
+ params.update(kwargs)
204
+ return f"{self.auth_endpoint}?{urlencode(params)}"
205
+
206
+ async def exchange_code(self, code: str) -> Credentials:
207
+ """用授权码换取token"""
208
+ data = {
209
+ "client_id": self.client_id,
210
+ "client_secret": self.client_secret,
211
+ "redirect_uri": self.redirect_uri,
212
+ "code": code,
213
+ "grant_type": "authorization_code",
214
+ }
215
+
216
+ try:
217
+ oauth_base_url = await get_oauth_proxy_url()
218
+ token_url = f"{oauth_base_url.rstrip('/')}/token"
219
+ response = await post_async(
220
+ token_url, data=data, headers={"Content-Type": "application/x-www-form-urlencoded"}
221
+ )
222
+ response.raise_for_status()
223
+
224
+ token_data = response.json()
225
+
226
+ # 计算过期时间
227
+ expires_at = None
228
+ if "expires_in" in token_data:
229
+ expires_in = int(token_data["expires_in"])
230
+ expires_at = datetime.now(timezone.utc) + timedelta(seconds=expires_in)
231
+
232
+ # 创建凭证对象
233
+ self.credentials = Credentials(
234
+ access_token=token_data["access_token"],
235
+ refresh_token=token_data.get("refresh_token"),
236
+ client_id=self.client_id,
237
+ client_secret=self.client_secret,
238
+ expires_at=expires_at,
239
+ )
240
+
241
+ return self.credentials
242
+
243
+ except Exception as e:
244
+ error_msg = f"获取token失败: {str(e)}"
245
+ log.error(error_msg)
246
+ raise TokenError(error_msg)
247
+
248
+
249
+ class ServiceAccount:
250
+ """Service Account类"""
251
+
252
+ def __init__(
253
+ self, email: str, private_key: str, project_id: str = None, scopes: List[str] = None
254
+ ):
255
+ self.email = email
256
+ self.private_key = private_key
257
+ self.project_id = project_id
258
+ self.scopes = scopes or []
259
+
260
+ # 反代配置将在使用时异步获取
261
+ self.oauth_base_url = None
262
+ self.token_endpoint = None
263
+
264
+ self.access_token: Optional[str] = None
265
+ self.expires_at: Optional[datetime] = None
266
+
267
+ def is_expired(self) -> bool:
268
+ """检查token是否过期"""
269
+ if not self.expires_at:
270
+ return True
271
+
272
+ buffer = timedelta(minutes=3)
273
+ return (self.expires_at - buffer) <= datetime.now(timezone.utc)
274
+
275
+ def create_jwt(self) -> str:
276
+ """创建JWT令牌"""
277
+ now = int(time.time())
278
+
279
+ payload = {
280
+ "iss": self.email,
281
+ "scope": " ".join(self.scopes) if self.scopes else "",
282
+ "aud": self.token_endpoint,
283
+ "exp": now + 3600,
284
+ "iat": now,
285
+ }
286
+
287
+ return jwt.encode(payload, self.private_key, algorithm="RS256")
288
+
289
+ async def get_access_token(self) -> str:
290
+ """获取访问令牌"""
291
+ if not self.is_expired() and self.access_token:
292
+ return self.access_token
293
+
294
+ assertion = self.create_jwt()
295
+
296
+ data = {"grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer", "assertion": assertion}
297
+
298
+ try:
299
+ oauth_base_url = await get_oauth_proxy_url()
300
+ token_url = f"{oauth_base_url.rstrip('/')}/token"
301
+ response = await post_async(
302
+ token_url, data=data, headers={"Content-Type": "application/x-www-form-urlencoded"}
303
+ )
304
+ response.raise_for_status()
305
+
306
+ token_data = response.json()
307
+ self.access_token = token_data["access_token"]
308
+
309
+ if "expires_in" in token_data:
310
+ expires_in = int(token_data["expires_in"])
311
+ self.expires_at = datetime.now(timezone.utc) + timedelta(seconds=expires_in)
312
+
313
+ return self.access_token
314
+
315
+ except Exception as e:
316
+ error_msg = f"Service Account获取token失败: {str(e)}"
317
+ log.error(error_msg)
318
+ raise TokenError(error_msg)
319
+
320
+ @classmethod
321
+ def from_dict(cls, data: Dict[str, Any], scopes: List[str] = None) -> "ServiceAccount":
322
+ """从字典创建Service Account凭证"""
323
+ return cls(
324
+ email=data["client_email"],
325
+ private_key=data["private_key"],
326
+ project_id=data.get("project_id"),
327
+ scopes=scopes,
328
+ )
329
+
330
+
331
+ # 工具函数
332
+ async def get_user_info(credentials: Credentials) -> Optional[Dict[str, Any]]:
333
+ """获取用户信息"""
334
+ await credentials.refresh_if_needed()
335
+
336
+ try:
337
+ googleapis_base_url = await get_googleapis_proxy_url()
338
+ userinfo_url = f"{googleapis_base_url.rstrip('/')}/oauth2/v2/userinfo"
339
+ response = await get_async(
340
+ userinfo_url, headers={"Authorization": f"Bearer {credentials.access_token}"}
341
+ )
342
+ response.raise_for_status()
343
+ return response.json()
344
+ except Exception as e:
345
+ log.error(f"获取用户信息失败: {e}")
346
+ return None
347
+
348
+
349
+ async def get_user_email(credentials: Credentials) -> Optional[str]:
350
+ """获取用户邮箱地址"""
351
+ try:
352
+ # 确保凭证有效
353
+ await credentials.refresh_if_needed()
354
+
355
+ # 调用Google userinfo API获取邮箱
356
+ user_info = await get_user_info(credentials)
357
+ if user_info:
358
+ email = user_info.get("email")
359
+ if email:
360
+ log.info(f"成功获取邮箱地址: {email}")
361
+ return email
362
+ else:
363
+ log.warning(f"userinfo响应中没有邮箱信息: {user_info}")
364
+ return None
365
+ else:
366
+ log.warning("获取用户信息失败")
367
+ return None
368
+
369
+ except Exception as e:
370
+ log.error(f"获取用户邮箱失败: {e}")
371
+ return None
372
+
373
+
374
+ async def fetch_user_email_from_file(cred_data: Dict[str, Any]) -> Optional[str]:
375
+ """从凭证数据获取用户邮箱地址(支持统一存储)"""
376
+ try:
377
+ # 直接从凭证数据创建凭证对象
378
+ credentials = Credentials.from_dict(cred_data)
379
+ if not credentials or not credentials.access_token:
380
+ log.warning("无法从凭证数据创建凭证对象或获取访问令牌")
381
+ return None
382
+
383
+ # 获取邮箱
384
+ return await get_user_email(credentials)
385
+
386
+ except Exception as e:
387
+ log.error(f"从凭证数据获取用户邮箱失败: {e}")
388
+ return None
389
+
390
+
391
+ async def validate_token(token: str) -> Optional[Dict[str, Any]]:
392
+ """验证访问令牌"""
393
+ try:
394
+ oauth_base_url = await get_oauth_proxy_url()
395
+ tokeninfo_url = f"{oauth_base_url.rstrip('/')}/tokeninfo?access_token={token}"
396
+
397
+ response = await get_async(tokeninfo_url)
398
+ response.raise_for_status()
399
+ return response.json()
400
+ except Exception as e:
401
+ log.error(f"验证令牌失败: {e}")
402
+ return None
403
+
404
+
405
+ async def enable_required_apis(credentials: Credentials, project_id: str) -> bool:
406
+ """自动启用必需的API服务"""
407
+ try:
408
+ # 确保凭证有效
409
+ if credentials.is_expired() and credentials.refresh_token:
410
+ await credentials.refresh()
411
+
412
+ headers = {
413
+ "Authorization": f"Bearer {credentials.access_token}",
414
+ "Content-Type": "application/json",
415
+ "User-Agent": "geminicli-oauth/1.0",
416
+ }
417
+
418
+ # 需要启用的服务列表
419
+ required_services = [
420
+ "geminicloudassist.googleapis.com", # Gemini Cloud Assist API
421
+ "cloudaicompanion.googleapis.com", # Gemini for Google Cloud API
422
+ ]
423
+
424
+ for service in required_services:
425
+ log.info(f"正在检查并启用服务: {service}")
426
+
427
+ # 检查服务是否已启用
428
+ service_usage_base_url = await get_service_usage_api_url()
429
+ check_url = (
430
+ f"{service_usage_base_url.rstrip('/')}/v1/projects/{project_id}/services/{service}"
431
+ )
432
+ try:
433
+ check_response = await get_async(check_url, headers=headers)
434
+ if check_response.status_code == 200:
435
+ service_data = check_response.json()
436
+ if service_data.get("state") == "ENABLED":
437
+ log.info(f"服务 {service} 已启用")
438
+ continue
439
+ except Exception as e:
440
+ log.debug(f"检查服务状态失败,将尝试启用: {e}")
441
+
442
+ # 启用服务
443
+ enable_url = f"{service_usage_base_url.rstrip('/')}/v1/projects/{project_id}/services/{service}:enable"
444
+ try:
445
+ enable_response = await post_async(enable_url, headers=headers, json={})
446
+
447
+ if enable_response.status_code in [200, 201]:
448
+ log.info(f"✅ 成功启用服务: {service}")
449
+ elif enable_response.status_code == 400:
450
+ error_data = enable_response.json()
451
+ if "already enabled" in error_data.get("error", {}).get("message", "").lower():
452
+ log.info(f"✅ 服务 {service} 已经启用")
453
+ else:
454
+ log.warning(f"⚠️ 启用服务 {service} 时出现警告: {error_data}")
455
+ else:
456
+ log.warning(
457
+ f"⚠️ 启用服务 {service} 失败: {enable_response.status_code} - {enable_response.text}"
458
+ )
459
+
460
+ except Exception as e:
461
+ log.warning(f"⚠️ 启用服务 {service} 时发生异常: {e}")
462
+
463
+ return True
464
+
465
+ except Exception as e:
466
+ log.error(f"启用API服务时发生错误: {e}")
467
+ return False
468
+
469
+
470
+ async def get_user_projects(credentials: Credentials) -> List[Dict[str, Any]]:
471
+ """获取用户可访问的Google Cloud项目列表"""
472
+ try:
473
+ # 确保凭证有效
474
+ if credentials.is_expired() and credentials.refresh_token:
475
+ await credentials.refresh()
476
+
477
+ headers = {
478
+ "Authorization": f"Bearer {credentials.access_token}",
479
+ "User-Agent": "geminicli-oauth/1.0",
480
+ }
481
+
482
+ # 使用Resource Manager API的正确域名和端点
483
+ resource_manager_base_url = await get_resource_manager_api_url()
484
+ url = f"{resource_manager_base_url.rstrip('/')}/v1/projects"
485
+ log.info(f"正在调用API: {url}")
486
+ response = await get_async(url, headers=headers)
487
+
488
+ log.info(f"API响应状态码: {response.status_code}")
489
+ if response.status_code != 200:
490
+ log.error(f"API响应内容: {response.text}")
491
+
492
+ if response.status_code == 200:
493
+ data = response.json()
494
+ projects = data.get("projects", [])
495
+ # 只返回活跃的项目
496
+ active_projects = [
497
+ project for project in projects if project.get("lifecycleState") == "ACTIVE"
498
+ ]
499
+ log.info(f"获取到 {len(active_projects)} 个活跃项目")
500
+ return active_projects
501
+ else:
502
+ log.warning(f"获取项目列表失败: {response.status_code} - {response.text}")
503
+ return []
504
+
505
+ except Exception as e:
506
+ log.error(f"获取用户项目列表失败: {e}")
507
+ return []
508
+
509
+
510
+ async def select_default_project(projects: List[Dict[str, Any]]) -> Optional[str]:
511
+ """从项目列表中选择默认项目"""
512
+ if not projects:
513
+ return None
514
+
515
+ # 策略1:查找显示名称或项目ID包含"default"的项目
516
+ for project in projects:
517
+ display_name = project.get("displayName", "").lower()
518
+ # Google API returns projectId in camelCase
519
+ project_id = project.get("projectId", "")
520
+ if "default" in display_name or "default" in project_id.lower():
521
+ log.info(f"选择默认项目: {project_id} ({project.get('displayName', project_id)})")
522
+ return project_id
523
+
524
+ # 策略2:选择第一个项目
525
+ first_project = projects[0]
526
+ # Google API returns projectId in camelCase
527
+ project_id = first_project.get("projectId", "")
528
+ log.info(
529
+ f"选择第一个项目作为默认: {project_id} ({first_project.get('displayName', project_id)})"
530
+ )
531
+ return project_id
532
+
533
+
534
+ async def fetch_project_id_and_tier(
535
+ access_token: str,
536
+ user_agent: str,
537
+ api_base_url: str,
538
+ include_credits: bool = False,
539
+ ) -> Tuple[Optional[str], Optional[str]] | Tuple[Optional[str], Optional[str], Optional[int]]:
540
+ """
541
+ 从 API 获取 project_id 和订阅等级
542
+
543
+ Args:
544
+ access_token: Google OAuth access token
545
+ user_agent: User-Agent header
546
+ api_base_url: API base URL (e.g., antigravity or code assist endpoint)
547
+
548
+ Returns:
549
+ 默认返回 (project_id, subscription_tier)
550
+ 当 include_credits=True 时返回 (project_id, subscription_tier, credit_amount)
551
+ subscription_tier 可能是 "FREE", "PRO", "ULTRA" 或 None
552
+ credit_amount 为积分数量(整数)或 None
553
+ """
554
+ headers = {
555
+ 'User-Agent': user_agent,
556
+ 'Authorization': f'Bearer {access_token}',
557
+ 'Content-Type': 'application/json',
558
+ 'Accept-Encoding': 'gzip'
559
+ }
560
+
561
+ def _map_raw_tier(raw_tier: Optional[str]) -> Optional[str]:
562
+ """将 loadCodeAssist 返回的 raw tier 映射为统一 tier。"""
563
+ if not raw_tier:
564
+ return None
565
+
566
+ tier_mapping = {
567
+ "g1-ultra-tier": "ultra",
568
+ "ws-ai-ultra-business-tier": "ultra",
569
+ "g1-pro-tier": "pro",
570
+ "helium-tier": "pro",
571
+ "standard-tier": "pro",
572
+ "free-tier": "free",
573
+ }
574
+
575
+ return tier_mapping.get(raw_tier.lower(), "pro")
576
+
577
+ subscription_tier = None
578
+ credit_amount: Optional[int] = None
579
+
580
+ # 步骤 1: 尝试 loadCodeAssist
581
+ try:
582
+ project_id, raw_tier, raw_credit_amount = await _try_load_code_assist(api_base_url, headers)
583
+ subscription_tier = _map_raw_tier(raw_tier)
584
+
585
+ if raw_credit_amount is not None:
586
+ try:
587
+ credit_amount = int(raw_credit_amount)
588
+ log.info(
589
+ f"[fetch_project_id_and_tier] Found credit_amount: {credit_amount}"
590
+ )
591
+ except (TypeError, ValueError):
592
+ log.warning(
593
+ f"[fetch_project_id_and_tier] Invalid credit_amount: {raw_credit_amount}"
594
+ )
595
+
596
+ if raw_tier:
597
+ log.info(
598
+ f"[fetch_project_id_and_tier] Raw tier '{raw_tier}' mapped to '{subscription_tier}'"
599
+ )
600
+
601
+ if project_id:
602
+ if include_credits:
603
+ return project_id, subscription_tier, credit_amount
604
+ return project_id, subscription_tier
605
+
606
+ log.warning("[fetch_project_id_and_tier] loadCodeAssist did not return project_id, falling back to onboardUser")
607
+
608
+ except Exception as e:
609
+ log.warning(f"[fetch_project_id_and_tier] loadCodeAssist failed: {type(e).__name__}: {e}")
610
+ log.warning("[fetch_project_id_and_tier] Falling back to onboardUser")
611
+
612
+ # 步骤 2: 回退到 onboardUser
613
+ try:
614
+ project_id = await _try_onboard_user(api_base_url, headers)
615
+ if project_id:
616
+ if include_credits:
617
+ return project_id, subscription_tier, credit_amount
618
+ return project_id, subscription_tier
619
+
620
+ log.error("[fetch_project_id_and_tier] Failed to get project_id from both loadCodeAssist and onboardUser")
621
+ if include_credits:
622
+ return None, subscription_tier, credit_amount
623
+ return None, subscription_tier
624
+
625
+ except Exception as e:
626
+ log.error(f"[fetch_project_id_and_tier] onboardUser failed: {type(e).__name__}: {e}")
627
+ import traceback
628
+ log.debug(f"[fetch_project_id_and_tier] Traceback: {traceback.format_exc()}")
629
+ if include_credits:
630
+ return None, subscription_tier, credit_amount
631
+ return None, subscription_tier
632
+
633
+
634
+ async def _try_load_code_assist(
635
+ api_base_url: str,
636
+ headers: dict
637
+ ) -> Tuple[Optional[str], Optional[str], Optional[str]]:
638
+ """
639
+ 尝试通过 loadCodeAssist 获取 project_id 和订阅等级
640
+
641
+ Returns:
642
+ (project_id, subscription_tier, credit_amount) 元组
643
+ subscription_tier 可能是 "FREE", "PRO", "ULTRA" 或 None
644
+ credit_amount 为字符串格式积分或 None
645
+ """
646
+ request_url = f"{api_base_url.rstrip('/')}/v1internal:loadCodeAssist"
647
+ request_body = {
648
+ "metadata": {
649
+ "ideType": "ANTIGRAVITY"
650
+ }
651
+ }
652
+
653
+ log.debug(f"[loadCodeAssist] Fetching project_id from: {request_url}")
654
+ log.debug(f"[loadCodeAssist] Request body: {request_body}")
655
+
656
+ response = await post_async(
657
+ request_url,
658
+ json=request_body,
659
+ headers=headers,
660
+ timeout=30.0,
661
+ )
662
+
663
+ log.debug(f"[loadCodeAssist] Response status: {response.status_code}")
664
+
665
+ if response.status_code == 200:
666
+ response_text = response.text
667
+ log.debug(f"[loadCodeAssist] Response body: {response_text}")
668
+
669
+ data = response.json()
670
+ log.debug(f"[loadCodeAssist] Response JSON keys: {list(data.keys())}")
671
+
672
+ # 提取订阅等级 - 优先使用 paidTier(更准确反映实际权益)
673
+ paid_tier = data.get("paidTier", {})
674
+ current_tier = data.get("currentTier", {})
675
+ available_credits = paid_tier.get("availableCredits", []) if isinstance(paid_tier, dict) else []
676
+
677
+ # paidTier.id 优先,然后是 currentTier.id
678
+ subscription_tier = None
679
+ if isinstance(paid_tier, dict) and paid_tier.get("id"):
680
+ subscription_tier = paid_tier.get("id")
681
+ log.info(f"[loadCodeAssist] Found paidTier: {subscription_tier}")
682
+ elif isinstance(current_tier, dict) and current_tier.get("id"):
683
+ subscription_tier = current_tier.get("id")
684
+ log.info(f"[loadCodeAssist] Found currentTier: {subscription_tier}")
685
+
686
+ # 提取积分数量(如果返回了 availableCredits)
687
+ credit_amount = None
688
+ if isinstance(available_credits, list) and available_credits:
689
+ first_credit = available_credits[0]
690
+ if isinstance(first_credit, dict):
691
+ credit_amount = first_credit.get("creditAmount")
692
+ if credit_amount is not None:
693
+ log.info(f"[loadCodeAssist] Found creditAmount: {credit_amount}")
694
+
695
+ # 检查是否有 currentTier(表示用户已激活)
696
+ if current_tier:
697
+ log.info("[loadCodeAssist] User is already activated")
698
+
699
+ # 使用服务器返回的 project_id
700
+ project_id = data.get("cloudaicompanionProject")
701
+ if project_id:
702
+ log.info(f"[loadCodeAssist] Successfully fetched project_id: {project_id}, tier: {subscription_tier}")
703
+ return project_id, subscription_tier, credit_amount
704
+
705
+ log.warning("[loadCodeAssist] No project_id in response")
706
+ return None, subscription_tier, credit_amount
707
+ else:
708
+ log.info("[loadCodeAssist] User not activated yet (no currentTier)")
709
+ return None, None, credit_amount
710
+ else:
711
+ log.warning(f"[loadCodeAssist] Failed: HTTP {response.status_code}")
712
+ log.warning(f"[loadCodeAssist] Response body: {response.text[:500]}")
713
+ raise Exception(f"HTTP {response.status_code}: {response.text[:200]}")
714
+
715
+
716
+ async def _try_onboard_user(
717
+ api_base_url: str,
718
+ headers: dict
719
+ ) -> Optional[str]:
720
+ """
721
+ 尝试通过 onboardUser 获取 project_id(长时间运行操作,需要轮询)
722
+
723
+ Returns:
724
+ project_id 或 None
725
+ """
726
+ request_url = f"{api_base_url.rstrip('/')}/v1internal:onboardUser"
727
+
728
+ # 首先需要获取用户的 tier 信息
729
+ tier_id = await _get_onboard_tier(api_base_url, headers)
730
+ if not tier_id:
731
+ log.error("[onboardUser] Failed to determine user tier")
732
+ return None
733
+
734
+ log.info(f"[onboardUser] User tier: {tier_id}")
735
+
736
+ # 构造 onboardUser 请求
737
+ # 注意:FREE tier 不应该包含 cloudaicompanionProject
738
+ request_body = {
739
+ "tierId": tier_id,
740
+ "metadata": {
741
+ "ideType": "ANTIGRAVITY",
742
+ "platform": "PLATFORM_UNSPECIFIED",
743
+ "pluginType": "GEMINI"
744
+ }
745
+ }
746
+
747
+ log.debug(f"[onboardUser] Request URL: {request_url}")
748
+ log.debug(f"[onboardUser] Request body: {request_body}")
749
+
750
+ # onboardUser 是长时间运行操作,需要轮询
751
+ # 最多等待 10 秒(5 次 * 2 秒)
752
+ max_attempts = 5
753
+ attempt = 0
754
+
755
+ while attempt < max_attempts:
756
+ attempt += 1
757
+ log.debug(f"[onboardUser] Polling attempt {attempt}/{max_attempts}")
758
+
759
+ response = await post_async(
760
+ request_url,
761
+ json=request_body,
762
+ headers=headers,
763
+ timeout=30.0,
764
+ )
765
+
766
+ log.debug(f"[onboardUser] Response status: {response.status_code}")
767
+
768
+ if response.status_code == 200:
769
+ data = response.json()
770
+ log.debug(f"[onboardUser] Response data: {data}")
771
+
772
+ # 检查长时间运行操作是否完成
773
+ if data.get("done"):
774
+ log.info("[onboardUser] Operation completed")
775
+
776
+ # 从响应中提取 project_id
777
+ response_data = data.get("response", {})
778
+ project_obj = response_data.get("cloudaicompanionProject", {})
779
+
780
+ if isinstance(project_obj, dict):
781
+ project_id = project_obj.get("id")
782
+ elif isinstance(project_obj, str):
783
+ project_id = project_obj
784
+ else:
785
+ project_id = None
786
+
787
+ if project_id:
788
+ log.info(f"[onboardUser] Successfully fetched project_id: {project_id}")
789
+ return project_id
790
+ else:
791
+ log.warning("[onboardUser] Operation completed but no project_id in response")
792
+ return None
793
+ else:
794
+ log.debug("[onboardUser] Operation still in progress, waiting 2 seconds...")
795
+ await asyncio.sleep(2)
796
+ else:
797
+ log.warning(f"[onboardUser] Failed: HTTP {response.status_code}")
798
+ log.warning(f"[onboardUser] Response body: {response.text[:500]}")
799
+ raise Exception(f"HTTP {response.status_code}: {response.text[:200]}")
800
+
801
+ log.error("[onboardUser] Timeout: Operation did not complete within 10 seconds")
802
+ return None
803
+
804
+
805
+ async def _get_onboard_tier(
806
+ api_base_url: str,
807
+ headers: dict
808
+ ) -> Optional[str]:
809
+ """
810
+ 从 loadCodeAssist 响应中获取用户应该注册的 tier
811
+
812
+ Returns:
813
+ tier_id (如 "FREE", "STANDARD", "LEGACY") 或 None
814
+ """
815
+ request_url = f"{api_base_url.rstrip('/')}/v1internal:loadCodeAssist"
816
+ request_body = {
817
+ "metadata": {
818
+ "ideType": "ANTIGRAVITY",
819
+ "platform": "PLATFORM_UNSPECIFIED",
820
+ "pluginType": "GEMINI"
821
+ }
822
+ }
823
+
824
+ log.debug(f"[_get_onboard_tier] Fetching tier info from: {request_url}")
825
+
826
+ response = await post_async(
827
+ request_url,
828
+ json=request_body,
829
+ headers=headers,
830
+ timeout=30.0,
831
+ )
832
+
833
+ if response.status_code == 200:
834
+ data = response.json()
835
+ log.debug(f"[_get_onboard_tier] Response data: {data}")
836
+
837
+ # 查找默认的 tier
838
+ allowed_tiers = data.get("allowedTiers", [])
839
+ for tier in allowed_tiers:
840
+ if tier.get("isDefault"):
841
+ tier_id = tier.get("id")
842
+ log.info(f"[_get_onboard_tier] Found default tier: {tier_id}")
843
+ return tier_id
844
+
845
+ # 如果没有默认 tier,使用 LEGACY 作为回退
846
+ log.warning("[_get_onboard_tier] No default tier found, using LEGACY")
847
+ return "LEGACY"
848
+ else:
849
+ log.error(f"[_get_onboard_tier] Failed to fetch tier info: HTTP {response.status_code}")
850
+ return None
851
+
852
+
src/httpx_client.py ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 通用的HTTP客户端模块
3
+ 为所有需要使用httpx的模块提供统一的客户端配置和方法
4
+ 保持通用性,不与特定业务逻辑耦合
5
+ """
6
+
7
+ from contextlib import asynccontextmanager
8
+ from typing import Any, AsyncGenerator, Dict, Optional
9
+
10
+ import httpx
11
+
12
+ from config import get_proxy_config
13
+ from log import log
14
+
15
+
16
+ class HttpxClientManager:
17
+ """通用HTTP客户端管理器"""
18
+
19
+ async def get_client_kwargs(self, timeout: float = 30.0, **kwargs) -> Dict[str, Any]:
20
+ """获取httpx客户端的通用配置参数"""
21
+ client_kwargs = {"timeout": timeout, **kwargs}
22
+
23
+ # 动态读取代理配置,支持热更新
24
+ current_proxy_config = await get_proxy_config()
25
+ if current_proxy_config:
26
+ client_kwargs["proxy"] = current_proxy_config
27
+
28
+ return client_kwargs
29
+
30
+ @asynccontextmanager
31
+ async def get_client(
32
+ self, timeout: float = 30.0, **kwargs
33
+ ) -> AsyncGenerator[httpx.AsyncClient, None]:
34
+ """获取配置好的异步HTTP客户端"""
35
+ client_kwargs = await self.get_client_kwargs(timeout=timeout, **kwargs)
36
+
37
+ async with httpx.AsyncClient(**client_kwargs) as client:
38
+ yield client
39
+
40
+ @asynccontextmanager
41
+ async def get_streaming_client(
42
+ self, timeout: float = None, **kwargs
43
+ ) -> AsyncGenerator[httpx.AsyncClient, None]:
44
+ """获取用于流式请求的HTTP客户端(无超时限制)"""
45
+ client_kwargs = await self.get_client_kwargs(timeout=timeout, **kwargs)
46
+
47
+ # 创建独立的客户端实例用于流式处理
48
+ client = httpx.AsyncClient(**client_kwargs)
49
+ try:
50
+ yield client
51
+ finally:
52
+ # 确保无论发生什么都关闭客户端
53
+ try:
54
+ await client.aclose()
55
+ except Exception as e:
56
+ log.warning(f"Error closing streaming client: {e}")
57
+
58
+
59
+ # 全局HTTP客户端管理器实例
60
+ http_client = HttpxClientManager()
61
+
62
+
63
+ # 通用的异步方法
64
+ async def get_async(
65
+ url: str, headers: Optional[Dict[str, str]] = None, timeout: float = 30.0, **kwargs
66
+ ) -> httpx.Response:
67
+ """通用异步GET请求"""
68
+ async with http_client.get_client(timeout=timeout, **kwargs) as client:
69
+ return await client.get(url, headers=headers)
70
+
71
+
72
+ async def post_async(
73
+ url: str,
74
+ data: Any = None,
75
+ json: Any = None,
76
+ headers: Optional[Dict[str, str]] = None,
77
+ timeout: float = 900.0,
78
+ **kwargs,
79
+ ) -> httpx.Response:
80
+ """通用异步POST请求"""
81
+ async with http_client.get_client(timeout=timeout, **kwargs) as client:
82
+ return await client.post(url, data=data, json=json, headers=headers)
83
+
84
+
85
+ # 调试用:设为 True 时所有流式请求都返回 429
86
+ _MOCK_STREAM_429 = False
87
+
88
+ async def stream_post_async(
89
+ url: str,
90
+ body: Dict[str, Any],
91
+ native: bool = False,
92
+ headers: Optional[Dict[str, str]] = None,
93
+ **kwargs,
94
+ ):
95
+ """流式异步POST请求"""
96
+ if _MOCK_STREAM_429:
97
+ from fastapi import Response
98
+ import json
99
+ log.warning(f"[MOCK] stream_post_async: 返回模拟429错误")
100
+ yield Response(
101
+ content=json.dumps({"error": {"code": 429, "message": "mock rate limit", "status": "RESOURCE_EXHAUSTED"}}),
102
+ status_code=429,
103
+ )
104
+ return
105
+
106
+ async with http_client.get_streaming_client(**kwargs) as client:
107
+ async with client.stream("POST", url, json=body, headers=headers) as r:
108
+ # 错误直接返回
109
+ if r.status_code != 200:
110
+ from fastapi import Response
111
+ yield Response(await r.aread(), r.status_code, dict(r.headers))
112
+ return
113
+
114
+ # 如果native=True,直接返回bytes流
115
+ if native:
116
+ async for chunk in r.aiter_bytes():
117
+ yield chunk
118
+ else:
119
+ # 通过aiter_lines转化成str流返回
120
+ async for line in r.aiter_lines():
121
+ yield line
src/keeplive.py ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 保活服务模块
3
+ 定期向配置的URL发送GET请求,保持服务在线
4
+ 未配置保活URL时不启动任何任务,零资源占用
5
+ """
6
+
7
+ import asyncio
8
+ from typing import Optional
9
+
10
+ from config import get_keepalive_interval, get_keepalive_url
11
+ from log import log
12
+ from src.httpx_client import get_async
13
+
14
+
15
+ class KeepAliveService:
16
+ """保活服务:定期向指定URL发送GET请求"""
17
+
18
+ def __init__(self):
19
+ self._task: Optional[asyncio.Task] = None
20
+
21
+ async def _run(self, url: str, interval: int):
22
+ """保活循环,读取到有效URL才会被调用"""
23
+ log.info(f"[KeepAlive] 保活任务启动,URL={url},间隔={interval}s")
24
+ while True:
25
+ try:
26
+ response = await get_async(url, timeout=30.0)
27
+ log.info(f"[KeepAlive] GET {url} -> {response.status_code}")
28
+ except asyncio.CancelledError:
29
+ raise
30
+ except Exception as e:
31
+ log.warning(f"[KeepAlive] GET {url} 失败: {e}")
32
+
33
+ try:
34
+ await asyncio.sleep(interval)
35
+ except asyncio.CancelledError:
36
+ raise
37
+
38
+ async def start(self):
39
+ """
40
+ 启动保活服务。
41
+ 仅当配置了有效的保活URL时才创建后台任务,否则零开销。
42
+ """
43
+ if self._task and not self._task.done():
44
+ # 已有任务在运行,不重复启动
45
+ return
46
+
47
+ url = await get_keepalive_url()
48
+ interval = await get_keepalive_interval()
49
+
50
+ if not url or not url.strip():
51
+ log.debug("[KeepAlive] 未配置保活URL,保活服务不启动")
52
+ return
53
+
54
+ if interval <= 0:
55
+ log.warning(f"[KeepAlive] 保活间隔无效({interval}),保活服务不启动")
56
+ return
57
+
58
+ self._task = asyncio.create_task(
59
+ self._run(url.strip(), interval), name="keepalive_service"
60
+ )
61
+
62
+ async def stop(self):
63
+ """停止保活服务"""
64
+ if self._task and not self._task.done():
65
+ self._task.cancel()
66
+ try:
67
+ await self._task
68
+ except asyncio.CancelledError:
69
+ pass
70
+ log.info("[KeepAlive] 保活服务已停止")
71
+ self._task = None
72
+
73
+ async def restart(self):
74
+ """
75
+ 重启保活服务。
76
+ 配置变更时调用,会停止旧任务并根据最新配置决定是否启动新任务。
77
+ """
78
+ await self.stop()
79
+ await self.start()
80
+
81
+ @property
82
+ def is_running(self) -> bool:
83
+ """当前保活任务是否在运行"""
84
+ return self._task is not None and not self._task.done()
85
+
86
+
87
+ # 全局保活服务实例
88
+ keepalive_service = KeepAliveService()
src/models.py ADDED
@@ -0,0 +1,379 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Any, Dict, List, Optional, Union
2
+
3
+ from pydantic import BaseModel, Field
4
+
5
+
6
+ # Pydantic v1/v2 兼容性辅助函数
7
+ def model_to_dict(model: BaseModel) -> Dict[str, Any]:
8
+ """
9
+ 兼容 Pydantic v1 和 v2 的模型转字典方法,排除 None 值
10
+ - v1: model.dict(exclude_none=True)
11
+ - v2: model.model_dump(exclude_none=True)
12
+ """
13
+ if hasattr(model, 'model_dump'):
14
+ # Pydantic v2
15
+ return model.model_dump(exclude_none=True)
16
+ else:
17
+ # Pydantic v1
18
+ return model.dict(exclude_none=True)
19
+
20
+
21
+ # Common Models
22
+ class Model(BaseModel):
23
+ id: str
24
+ object: str = "model"
25
+ created: Optional[int] = None
26
+ owned_by: Optional[str] = "google"
27
+
28
+
29
+ class ModelList(BaseModel):
30
+ object: str = "list"
31
+ data: List[Model]
32
+
33
+
34
+ # OpenAI Models
35
+ class OpenAIToolFunction(BaseModel):
36
+ name: str
37
+ arguments: str # JSON string
38
+
39
+
40
+ class OpenAIToolCall(BaseModel):
41
+ id: str
42
+ type: str = "function"
43
+ function: OpenAIToolFunction
44
+
45
+
46
+ class OpenAITool(BaseModel):
47
+ type: str = "function"
48
+ function: Dict[str, Any]
49
+
50
+
51
+ class OpenAIChatMessage(BaseModel):
52
+ role: str
53
+ content: Union[str, List[Dict[str, Any]], None] = None
54
+ reasoning_content: Optional[str] = None
55
+ name: Optional[str] = None
56
+ tool_calls: Optional[List[OpenAIToolCall]] = None
57
+ tool_call_id: Optional[str] = None # for role="tool"
58
+
59
+
60
+ class OpenAIChatCompletionRequest(BaseModel):
61
+ model: str
62
+ messages: List[OpenAIChatMessage]
63
+ stream: bool = False
64
+ temperature: Optional[float] = Field(None, ge=0.0, le=2.0)
65
+ top_p: Optional[float] = Field(None, ge=0.0, le=1.0)
66
+ max_tokens: Optional[int] = Field(None, ge=1)
67
+ stop: Optional[Union[str, List[str]]] = None
68
+ frequency_penalty: Optional[float] = Field(None, ge=-2.0, le=2.0)
69
+ presence_penalty: Optional[float] = Field(None, ge=-2.0, le=2.0)
70
+ n: Optional[int] = Field(1, ge=1, le=128)
71
+ seed: Optional[int] = None
72
+ response_format: Optional[Dict[str, Any]] = None
73
+ top_k: Optional[int] = Field(None, ge=1)
74
+ tools: Optional[List[OpenAITool]] = None
75
+ tool_choice: Optional[Union[str, Dict[str, Any]]] = None
76
+
77
+ class Config:
78
+ extra = "allow" # Allow additional fields not explicitly defined
79
+
80
+
81
+ # 通用的聊天完成请求模型(兼容OpenAI和其他格式)
82
+ ChatCompletionRequest = OpenAIChatCompletionRequest
83
+
84
+
85
+ class OpenAIChatCompletionChoice(BaseModel):
86
+ index: int
87
+ message: OpenAIChatMessage
88
+ finish_reason: Optional[str] = None
89
+ logprobs: Optional[Dict[str, Any]] = None
90
+
91
+
92
+ class OpenAIChatCompletionResponse(BaseModel):
93
+ id: str
94
+ object: str = "chat.completion"
95
+ created: int
96
+ model: str
97
+ choices: List[OpenAIChatCompletionChoice]
98
+ usage: Optional[Dict[str, int]] = None
99
+ system_fingerprint: Optional[str] = None
100
+
101
+
102
+ class OpenAIDelta(BaseModel):
103
+ role: Optional[str] = None
104
+ content: Optional[str] = None
105
+ reasoning_content: Optional[str] = None
106
+
107
+
108
+ class OpenAIChatCompletionStreamChoice(BaseModel):
109
+ index: int
110
+ delta: OpenAIDelta
111
+ finish_reason: Optional[str] = None
112
+ logprobs: Optional[Dict[str, Any]] = None
113
+
114
+
115
+ class OpenAIChatCompletionStreamResponse(BaseModel):
116
+ id: str
117
+ object: str = "chat.completion.chunk"
118
+ created: int
119
+ model: str
120
+ choices: List[OpenAIChatCompletionStreamChoice]
121
+ system_fingerprint: Optional[str] = None
122
+
123
+
124
+ # Gemini Models
125
+ class GeminiPart(BaseModel):
126
+ text: Optional[str] = None
127
+ inlineData: Optional[Dict[str, Any]] = None
128
+ fileData: Optional[Dict[str, Any]] = None
129
+ thought: Optional[bool] = None # 改为 None,避免序列化时包含 False
130
+
131
+ class Config:
132
+ extra = "allow" # 允许额外字段(如 functionCall, functionResponse)
133
+
134
+
135
+ class GeminiContent(BaseModel):
136
+ role: str
137
+ parts: List[GeminiPart]
138
+
139
+
140
+ class GeminiSystemInstruction(BaseModel):
141
+ parts: List[GeminiPart]
142
+
143
+
144
+ class GeminiImageConfig(BaseModel):
145
+ """图片生成配置"""
146
+ aspect_ratio: Optional[str] = None # "1:1", "2:3", "3:2", "3:4", "4:3", "4:5", "5:4", "9:16", "16:9", "21:9"
147
+ image_size: Optional[str] = None # "1K", "2K", "4K"
148
+
149
+
150
+ class GeminiGenerationConfig(BaseModel):
151
+ temperature: Optional[float] = Field(None, ge=0.0, le=2.0)
152
+ topP: Optional[float] = Field(None, ge=0.0, le=1.0)
153
+ topK: Optional[int] = Field(None, ge=1)
154
+ maxOutputTokens: Optional[int] = Field(None, ge=1)
155
+ stopSequences: Optional[List[str]] = None
156
+ responseMimeType: Optional[str] = None
157
+ responseSchema: Optional[Dict[str, Any]] = None
158
+ candidateCount: Optional[int] = Field(None, ge=1, le=8)
159
+ seed: Optional[int] = None
160
+ frequencyPenalty: Optional[float] = Field(None, ge=-2.0, le=2.0)
161
+ presencePenalty: Optional[float] = Field(None, ge=-2.0, le=2.0)
162
+ thinkingConfig: Optional[Dict[str, Any]] = None
163
+ # 图片生成相关参数
164
+ response_modalities: Optional[List[str]] = None # ["TEXT", "IMAGE"]
165
+ image_config: Optional[GeminiImageConfig] = None
166
+
167
+
168
+ class GeminiSafetySetting(BaseModel):
169
+ category: str
170
+ threshold: str
171
+
172
+
173
+ class GeminiRequest(BaseModel):
174
+ contents: List[GeminiContent]
175
+ systemInstruction: Optional[GeminiSystemInstruction] = None
176
+ generationConfig: Optional[GeminiGenerationConfig] = None
177
+ safetySettings: Optional[List[GeminiSafetySetting]] = None
178
+ tools: Optional[List[Dict[str, Any]]] = None
179
+ toolConfig: Optional[Dict[str, Any]] = None
180
+ cachedContent: Optional[str] = None
181
+
182
+ class Config:
183
+ extra = "allow" # 允许透传未定义的字段
184
+
185
+
186
+ class GeminiCandidate(BaseModel):
187
+ content: GeminiContent
188
+ finishReason: Optional[str] = None
189
+ index: int = 0
190
+ safetyRatings: Optional[List[Dict[str, Any]]] = None
191
+ citationMetadata: Optional[Dict[str, Any]] = None
192
+ tokenCount: Optional[int] = None
193
+
194
+
195
+ class GeminiUsageMetadata(BaseModel):
196
+ promptTokenCount: Optional[int] = None
197
+ candidatesTokenCount: Optional[int] = None
198
+ totalTokenCount: Optional[int] = None
199
+
200
+
201
+ class GeminiResponse(BaseModel):
202
+ candidates: List[GeminiCandidate]
203
+ usageMetadata: Optional[GeminiUsageMetadata] = None
204
+ modelVersion: Optional[str] = None
205
+
206
+
207
+ # Claude Models
208
+ class ClaudeContentBlock(BaseModel):
209
+ type: str # "text", "image", "tool_use", "tool_result"
210
+ text: Optional[str] = None
211
+ source: Optional[Dict[str, Any]] = None # for image type
212
+ id: Optional[str] = None # for tool_use
213
+ name: Optional[str] = None # for tool_use
214
+ input: Optional[Dict[str, Any]] = None # for tool_use
215
+ tool_use_id: Optional[str] = None # for tool_result
216
+ content: Optional[Union[str, List[Dict[str, Any]]]] = None # for tool_result
217
+
218
+
219
+ class ClaudeMessage(BaseModel):
220
+ role: str # "user" or "assistant"
221
+ content: Union[str, List[ClaudeContentBlock]]
222
+
223
+
224
+ class ClaudeTool(BaseModel):
225
+ name: str
226
+ description: Optional[str] = None
227
+ input_schema: Dict[str, Any]
228
+
229
+
230
+ class ClaudeMetadata(BaseModel):
231
+ user_id: Optional[str] = None
232
+
233
+
234
+ class ClaudeRequest(BaseModel):
235
+ model: str
236
+ messages: List[ClaudeMessage]
237
+ max_tokens: int = Field(..., ge=1)
238
+ system: Optional[Union[str, List[Dict[str, Any]]]] = None
239
+ temperature: Optional[float] = Field(None, ge=0.0, le=1.0)
240
+ top_p: Optional[float] = Field(None, ge=0.0, le=1.0)
241
+ top_k: Optional[int] = Field(None, ge=1)
242
+ stop_sequences: Optional[List[str]] = None
243
+ stream: bool = False
244
+ metadata: Optional[ClaudeMetadata] = None
245
+ tools: Optional[List[ClaudeTool]] = None
246
+ tool_choice: Optional[Union[str, Dict[str, Any]]] = None
247
+
248
+ class Config:
249
+ extra = "allow"
250
+
251
+
252
+ class ClaudeUsage(BaseModel):
253
+ input_tokens: int
254
+ output_tokens: int
255
+
256
+
257
+ class ClaudeResponse(BaseModel):
258
+ id: str
259
+ type: str = "message"
260
+ role: str = "assistant"
261
+ content: List[ClaudeContentBlock]
262
+ model: str
263
+ stop_reason: Optional[str] = None
264
+ stop_sequence: Optional[str] = None
265
+ usage: ClaudeUsage
266
+
267
+
268
+ class ClaudeStreamEvent(BaseModel):
269
+ type: str # "message_start", "content_block_start", "content_block_delta", "content_block_stop", "message_delta", "message_stop"
270
+ message: Optional[ClaudeResponse] = None
271
+ index: Optional[int] = None
272
+ content_block: Optional[ClaudeContentBlock] = None
273
+ delta: Optional[Dict[str, Any]] = None
274
+ usage: Optional[ClaudeUsage] = None
275
+
276
+ class Config:
277
+ extra = "allow"
278
+
279
+
280
+ # Error Models
281
+ class APIError(BaseModel):
282
+ message: str
283
+ type: str = "api_error"
284
+ code: Optional[int] = None
285
+
286
+
287
+ class ErrorResponse(BaseModel):
288
+ error: APIError
289
+
290
+
291
+ # Control Panel Models
292
+ class SystemStatus(BaseModel):
293
+ status: str
294
+ timestamp: str
295
+ credentials: Dict[str, int]
296
+ config: Dict[str, Any]
297
+ current_credential: str
298
+
299
+
300
+ class CredentialInfo(BaseModel):
301
+ filename: str
302
+ project_id: Optional[str] = None
303
+ status: Dict[str, Any]
304
+ size: Optional[int] = None
305
+ modified_time: Optional[str] = None
306
+ error: Optional[str] = None
307
+
308
+
309
+ class LogEntry(BaseModel):
310
+ timestamp: str
311
+ level: str
312
+ message: str
313
+ module: Optional[str] = None
314
+
315
+
316
+ class ConfigValue(BaseModel):
317
+ key: str
318
+ value: Any
319
+ env_locked: bool = False
320
+ description: Optional[str] = None
321
+
322
+
323
+ # Authentication Models
324
+ class AuthRequest(BaseModel):
325
+ project_id: Optional[str] = None
326
+ user_session: Optional[str] = None
327
+
328
+
329
+ class AuthResponse(BaseModel):
330
+ success: bool
331
+ auth_url: Optional[str] = None
332
+ state: Optional[str] = None
333
+ error: Optional[str] = None
334
+ credentials: Optional[Dict[str, Any]] = None
335
+ file_path: Optional[str] = None
336
+ requires_manual_project_id: Optional[bool] = None
337
+ requires_project_selection: Optional[bool] = None
338
+ available_projects: Optional[List[Dict[str, str]]] = None
339
+
340
+
341
+ class CredentialStatus(BaseModel):
342
+ disabled: bool = False
343
+ error_codes: List[int] = []
344
+ last_success: Optional[str] = None
345
+
346
+
347
+ # Web Routes Models
348
+ class LoginRequest(BaseModel):
349
+ password: str
350
+
351
+
352
+ class AuthStartRequest(BaseModel):
353
+ project_id: Optional[str] = None # 现在是可选的
354
+ mode: Optional[str] = "geminicli" # 凭证模式: geminicli 或 antigravity
355
+
356
+
357
+ class AuthCallbackRequest(BaseModel):
358
+ project_id: Optional[str] = None # 现在是可选的
359
+ mode: Optional[str] = "geminicli" # 凭证模式: geminicli 或 antigravity
360
+
361
+
362
+ class AuthCallbackUrlRequest(BaseModel):
363
+ callback_url: str # OAuth回调完整URL
364
+ project_id: Optional[str] = None # 可选的项目ID
365
+ mode: Optional[str] = "geminicli" # 凭证模式: geminicli 或 antigravity
366
+
367
+
368
+ class CredFileActionRequest(BaseModel):
369
+ filename: str
370
+ action: str # enable, disable, delete
371
+
372
+
373
+ class CredFileBatchActionRequest(BaseModel):
374
+ action: str # "enable", "disable", "delete"
375
+ filenames: List[str] # 批量操作的文件名列表
376
+
377
+
378
+ class ConfigSaveRequest(BaseModel):
379
+ config: dict
src/panel/__init__.py ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Panel模块 - 整合所有控制面板路由
3
+ """
4
+
5
+ from fastapi import APIRouter
6
+
7
+ from . import auth, creds, config_routes, logs, version, root
8
+
9
+
10
+ def create_router() -> APIRouter:
11
+ """创建并返回整合所有子路由的主路由器"""
12
+ router = APIRouter()
13
+
14
+ # 包含所有子路由
15
+ router.include_router(root.router)
16
+ router.include_router(auth.router)
17
+ router.include_router(creds.router)
18
+ router.include_router(config_routes.router)
19
+ router.include_router(logs.router)
20
+ router.include_router(version.router)
21
+
22
+ return router
23
+
24
+
25
+ # 导出主路由器
26
+ router = create_router()
27
+
28
+ # 导出常用工具
29
+ from .utils import ConnectionManager, is_mobile_user_agent, validate_mode, get_env_locked_keys
30
+
31
+ __all__ = [
32
+ "router",
33
+ "ConnectionManager",
34
+ "is_mobile_user_agent",
35
+ "validate_mode",
36
+ "get_env_locked_keys",
37
+ ]
src/panel/auth.py ADDED
@@ -0,0 +1,192 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 认证路由模块 - 处理 /auth/* 相关的HTTP请求
3
+ """
4
+
5
+ from fastapi import APIRouter, Depends, HTTPException
6
+ from fastapi.responses import JSONResponse
7
+
8
+ from log import log
9
+ from src.auth import (
10
+ asyncio_complete_auth_flow,
11
+ complete_auth_flow_from_callback_url,
12
+ create_auth_url,
13
+ get_auth_status,
14
+ verify_password,
15
+ )
16
+ from src.models import (
17
+ LoginRequest,
18
+ AuthStartRequest,
19
+ AuthCallbackRequest,
20
+ AuthCallbackUrlRequest,
21
+ )
22
+ from src.utils import verify_panel_token
23
+
24
+
25
+ # 创建路由器
26
+ router = APIRouter(prefix="/auth", tags=["auth"])
27
+
28
+
29
+ @router.post("/login")
30
+ async def login(request: LoginRequest):
31
+ """用户登录(简化版:直接返回密码作为token)"""
32
+ try:
33
+ if await verify_password(request.password):
34
+ # 直接使用密码作为token,简化认证流程
35
+ return JSONResponse(content={"token": request.password, "message": "登录成功"})
36
+ else:
37
+ raise HTTPException(status_code=401, detail="密码错误")
38
+ except HTTPException:
39
+ raise
40
+ except Exception as e:
41
+ log.error(f"登录失败: {e}")
42
+ raise HTTPException(status_code=500, detail=str(e))
43
+
44
+
45
+ @router.post("/start")
46
+ async def start_auth(request: AuthStartRequest, token: str = Depends(verify_panel_token)):
47
+ """开始认证流程,支持自动检测项目ID"""
48
+ try:
49
+ # 如果没有提供项目ID,尝试自动检测
50
+ project_id = request.project_id
51
+ if not project_id:
52
+ log.info("用户未提供项目ID,后续将使用自动检测...")
53
+
54
+ # 使用认证令牌作为用户会话标识
55
+ user_session = token if token else None
56
+ result = await create_auth_url(
57
+ project_id, user_session, mode=request.mode
58
+ )
59
+
60
+ if result["success"]:
61
+ return JSONResponse(
62
+ content={
63
+ "auth_url": result["auth_url"],
64
+ "state": result["state"],
65
+ "auto_project_detection": result.get("auto_project_detection", False),
66
+ "detected_project_id": result.get("detected_project_id"),
67
+ }
68
+ )
69
+ else:
70
+ raise HTTPException(status_code=500, detail=result["error"])
71
+
72
+ except HTTPException:
73
+ raise
74
+ except Exception as e:
75
+ log.error(f"开始认证流程失败: {e}")
76
+ raise HTTPException(status_code=500, detail=str(e))
77
+
78
+
79
+ @router.post("/callback")
80
+ async def auth_callback(request: AuthCallbackRequest, token: str = Depends(verify_panel_token)):
81
+ """处理认证回调,支持自动检测项目ID"""
82
+ try:
83
+ # 项目ID现在是可选的,在回调处理中进行自动检测
84
+ project_id = request.project_id
85
+
86
+ # 使用认证令牌作为用户会话标识
87
+ user_session = token if token else None
88
+ # 异步等待OAuth回调完成
89
+ result = await asyncio_complete_auth_flow(
90
+ project_id, user_session, mode=request.mode
91
+ )
92
+
93
+ if result["success"]:
94
+ # 单项目认证成功
95
+ return JSONResponse(
96
+ content={
97
+ "credentials": result["credentials"],
98
+ "file_path": result["file_path"],
99
+ "message": "认证成功,凭证已保存",
100
+ "auto_detected_project": result.get("auto_detected_project", False),
101
+ }
102
+ )
103
+ else:
104
+ # 如果需要手动项目ID或项目选择,在响应中标明
105
+ if result.get("requires_manual_project_id"):
106
+ # 使用JSON响应
107
+ return JSONResponse(
108
+ status_code=400,
109
+ content={"error": result["error"], "requires_manual_project_id": True},
110
+ )
111
+ elif result.get("requires_project_selection"):
112
+ # 返回项目列表供用户选择
113
+ return JSONResponse(
114
+ status_code=400,
115
+ content={
116
+ "error": result["error"],
117
+ "requires_project_selection": True,
118
+ "available_projects": result["available_projects"],
119
+ },
120
+ )
121
+ else:
122
+ raise HTTPException(status_code=400, detail=result["error"])
123
+
124
+ except HTTPException:
125
+ raise
126
+ except Exception as e:
127
+ log.error(f"处理认证回调失败: {e}")
128
+ raise HTTPException(status_code=500, detail=str(e))
129
+
130
+
131
+ @router.post("/callback-url")
132
+ async def auth_callback_url(request: AuthCallbackUrlRequest, token: str = Depends(verify_panel_token)):
133
+ """从回调URL直接完成认证"""
134
+ try:
135
+ # 验证URL格式
136
+ if not request.callback_url or not request.callback_url.startswith(("http://", "https://")):
137
+ raise HTTPException(status_code=400, detail="请提供有效的回调URL")
138
+
139
+ # 从回调URL完成认证
140
+ result = await complete_auth_flow_from_callback_url(
141
+ request.callback_url, request.project_id, mode=request.mode
142
+ )
143
+
144
+ if result["success"]:
145
+ # 单项目认证成功
146
+ return JSONResponse(
147
+ content={
148
+ "credentials": result["credentials"],
149
+ "file_path": result["file_path"],
150
+ "message": "从回调URL认证成功,凭证已保存",
151
+ "auto_detected_project": result.get("auto_detected_project", False),
152
+ }
153
+ )
154
+ else:
155
+ # 处理各种错误情况
156
+ if result.get("requires_manual_project_id"):
157
+ return JSONResponse(
158
+ status_code=400,
159
+ content={"error": result["error"], "requires_manual_project_id": True},
160
+ )
161
+ elif result.get("requires_project_selection"):
162
+ return JSONResponse(
163
+ status_code=400,
164
+ content={
165
+ "error": result["error"],
166
+ "requires_project_selection": True,
167
+ "available_projects": result["available_projects"],
168
+ },
169
+ )
170
+ else:
171
+ raise HTTPException(status_code=400, detail=result["error"])
172
+
173
+ except HTTPException:
174
+ raise
175
+ except Exception as e:
176
+ log.error(f"从回调URL处理认证失败: {e}")
177
+ raise HTTPException(status_code=500, detail=str(e))
178
+
179
+
180
+ @router.get("/status/{project_id}")
181
+ async def check_auth_status(project_id: str, token: str = Depends(verify_panel_token)):
182
+ """检查认证状态"""
183
+ try:
184
+ if not project_id:
185
+ raise HTTPException(status_code=400, detail="Project ID 不能为空")
186
+
187
+ status = get_auth_status(project_id)
188
+ return JSONResponse(content=status)
189
+
190
+ except Exception as e:
191
+ log.error(f"检查认证状态失败: {e}")
192
+ raise HTTPException(status_code=500, detail=str(e))
src/panel/config_routes.py ADDED
@@ -0,0 +1,224 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 配置路由模块 - 处理 /config/* 相关的HTTP请求
3
+ """
4
+
5
+ from fastapi import APIRouter, Depends, HTTPException
6
+ from fastapi.responses import JSONResponse
7
+
8
+ import config
9
+ from log import log
10
+ from src.keeplive import keepalive_service
11
+ from src.models import ConfigSaveRequest
12
+ from src.storage_adapter import get_storage_adapter
13
+ from src.utils import verify_panel_token
14
+ from .utils import get_env_locked_keys
15
+
16
+
17
+ # 创建路由器
18
+ router = APIRouter(prefix="/config", tags=["config"])
19
+
20
+
21
+ @router.get("/get")
22
+ async def get_config(token: str = Depends(verify_panel_token)):
23
+ """获取当前配置"""
24
+ try:
25
+
26
+
27
+ # 读取当前配置(包括环境变量和TOML文件中的配置)
28
+ current_config = {}
29
+
30
+ # 基础配置
31
+ current_config["code_assist_endpoint"] = await config.get_code_assist_endpoint()
32
+ current_config["credentials_dir"] = await config.get_credentials_dir()
33
+ current_config["proxy"] = await config.get_proxy_config() or ""
34
+
35
+ # 代理端点配置
36
+ current_config["oauth_proxy_url"] = await config.get_oauth_proxy_url()
37
+ current_config["googleapis_proxy_url"] = await config.get_googleapis_proxy_url()
38
+ current_config["resource_manager_api_url"] = await config.get_resource_manager_api_url()
39
+ current_config["service_usage_api_url"] = await config.get_service_usage_api_url()
40
+
41
+ # 自动封禁配置
42
+ current_config["auto_ban_enabled"] = await config.get_auto_ban_enabled()
43
+ current_config["auto_ban_error_codes"] = await config.get_auto_ban_error_codes()
44
+
45
+ # 429重试配置
46
+ current_config["retry_429_max_retries"] = await config.get_retry_429_max_retries()
47
+ current_config["retry_429_enabled"] = await config.get_retry_429_enabled()
48
+ current_config["retry_429_interval"] = await config.get_retry_429_interval()
49
+ # 抗截断配置
50
+ current_config["anti_truncation_max_attempts"] = await config.get_anti_truncation_max_attempts()
51
+
52
+ # 兼容性配置
53
+ current_config["compatibility_mode_enabled"] = await config.get_compatibility_mode_enabled()
54
+
55
+ # 思维链返回配置
56
+ current_config["return_thoughts_to_frontend"] = await config.get_return_thoughts_to_frontend()
57
+
58
+ # Antigravity流式转非流式配置
59
+ current_config["antigravity_stream2nostream"] = await config.get_antigravity_stream2nostream()
60
+
61
+ # 保活配置
62
+ current_config["keepalive_url"] = await config.get_keepalive_url()
63
+ current_config["keepalive_interval"] = await config.get_keepalive_interval()
64
+
65
+ # 服务器配置
66
+ current_config["host"] = await config.get_server_host()
67
+ current_config["port"] = await config.get_server_port()
68
+ current_config["api_password"] = await config.get_api_password()
69
+ current_config["panel_password"] = await config.get_panel_password()
70
+ current_config["password"] = await config.get_server_password()
71
+
72
+ # 从存储系统读取配置
73
+ storage_adapter = await get_storage_adapter()
74
+ storage_config = await storage_adapter.get_all_config()
75
+
76
+ # 获取环境变量锁定的配置键
77
+ env_locked_keys = get_env_locked_keys()
78
+
79
+ # 合并存储系统配置(不覆盖环境变量)
80
+ for key, value in storage_config.items():
81
+ if key not in env_locked_keys:
82
+ current_config[key] = value
83
+
84
+ return JSONResponse(content={"config": current_config, "env_locked": list(env_locked_keys)})
85
+
86
+ except Exception as e:
87
+ log.error(f"获取配置失败: {e}")
88
+ raise HTTPException(status_code=500, detail=str(e))
89
+
90
+
91
+ @router.post("/save")
92
+ async def save_config(request: ConfigSaveRequest, token: str = Depends(verify_panel_token)):
93
+ """保存配置"""
94
+ try:
95
+
96
+ new_config = request.config
97
+
98
+ log.debug(f"收到的配置数据: {list(new_config.keys())}")
99
+ log.debug(f"收到的password值: {new_config.get('password', 'NOT_FOUND')}")
100
+
101
+ # 验证配置项
102
+ if "retry_429_max_retries" in new_config:
103
+ if (
104
+ not isinstance(new_config["retry_429_max_retries"], int)
105
+ or new_config["retry_429_max_retries"] < 0
106
+ ):
107
+ raise HTTPException(status_code=400, detail="最大429重试次数必须是大于等于0的整数")
108
+
109
+ if "retry_429_enabled" in new_config:
110
+ if not isinstance(new_config["retry_429_enabled"], bool):
111
+ raise HTTPException(status_code=400, detail="429重试开关必须是布尔值")
112
+
113
+ # 验证新的配置项
114
+ if "retry_429_interval" in new_config:
115
+ try:
116
+ interval = float(new_config["retry_429_interval"])
117
+ if interval < 0.01 or interval > 10:
118
+ raise HTTPException(status_code=400, detail="429重试间隔必须在0.01-10秒之间")
119
+ except (ValueError, TypeError):
120
+ raise HTTPException(status_code=400, detail="429重试间隔必须是有效的数字")
121
+
122
+ if "anti_truncation_max_attempts" in new_config:
123
+ if (
124
+ not isinstance(new_config["anti_truncation_max_attempts"], int)
125
+ or new_config["anti_truncation_max_attempts"] < 1
126
+ or new_config["anti_truncation_max_attempts"] > 10
127
+ ):
128
+ raise HTTPException(
129
+ status_code=400, detail="抗截断最大重试次数必须是1-10之间的整数"
130
+ )
131
+
132
+ if "compatibility_mode_enabled" in new_config:
133
+ if not isinstance(new_config["compatibility_mode_enabled"], bool):
134
+ raise HTTPException(status_code=400, detail="兼容性模式开关必须是布尔值")
135
+
136
+ if "return_thoughts_to_frontend" in new_config:
137
+ if not isinstance(new_config["return_thoughts_to_frontend"], bool):
138
+ raise HTTPException(status_code=400, detail="思维链返回开关必须是布尔值")
139
+
140
+ if "antigravity_stream2nostream" in new_config:
141
+ if not isinstance(new_config["antigravity_stream2nostream"], bool):
142
+ raise HTTPException(status_code=400, detail="Antigravity流式转非流式开关必须是布尔值")
143
+
144
+ # 验证保活配置
145
+ if "keepalive_url" in new_config:
146
+ if not isinstance(new_config["keepalive_url"], str):
147
+ raise HTTPException(status_code=400, detail="保活URL必须是字符串")
148
+
149
+ if "keepalive_interval" in new_config:
150
+ try:
151
+ interval = int(new_config["keepalive_interval"])
152
+ if interval < 5 or interval > 86400:
153
+ raise HTTPException(status_code=400, detail="保活间隔必须在 5-86400 秒之间")
154
+ new_config["keepalive_interval"] = interval
155
+ except (ValueError, TypeError):
156
+ raise HTTPException(status_code=400, detail="保活间隔必须是有效整数")
157
+ # 验证服务器配置
158
+ if "host" in new_config:
159
+ if not isinstance(new_config["host"], str) or not new_config["host"].strip():
160
+ raise HTTPException(status_code=400, detail="服务器主机地址不能为空")
161
+
162
+ if "port" in new_config:
163
+ if (
164
+ not isinstance(new_config["port"], int)
165
+ or new_config["port"] < 1
166
+ or new_config["port"] > 65535
167
+ ):
168
+ raise HTTPException(status_code=400, detail="端口号必须是1-65535之间的整数")
169
+
170
+ if "api_password" in new_config:
171
+ if not isinstance(new_config["api_password"], str):
172
+ raise HTTPException(status_code=400, detail="API访问密码必须是字符串")
173
+
174
+ if "panel_password" in new_config:
175
+ if not isinstance(new_config["panel_password"], str):
176
+ raise HTTPException(status_code=400, detail="控制面板密码必须是字符串")
177
+
178
+ if "password" in new_config:
179
+ if not isinstance(new_config["password"], str):
180
+ raise HTTPException(status_code=400, detail="访问密码必须是字符串")
181
+
182
+ # 获取环境变量锁定的配置键
183
+ env_locked_keys = get_env_locked_keys()
184
+
185
+ # 直接使用存储适配器保存配置
186
+ storage_adapter = await get_storage_adapter()
187
+ for key, value in new_config.items():
188
+ if key not in env_locked_keys:
189
+ await storage_adapter.set_config(key, value)
190
+ if key in ("password", "api_password", "panel_password"):
191
+ log.debug(f"设置{key}字段为: {value}")
192
+
193
+ # 重新加载配置缓存(关键!)
194
+ await config.reload_config()
195
+
196
+ # 如果保活相关配置发生变化,立即重启保活服务
197
+ keepalive_keys = {"keepalive_url", "keepalive_interval"}
198
+ if keepalive_keys & set(new_config.keys()):
199
+ try:
200
+ await keepalive_service.restart()
201
+ except Exception as e:
202
+ log.warning(f"重启保活服务失败: {e}")
203
+
204
+ # 验证保存后的结果
205
+ test_api_password = await config.get_api_password()
206
+ test_panel_password = await config.get_panel_password()
207
+ test_password = await config.get_server_password()
208
+ log.debug(f"保存后立即读取的API密码: {test_api_password}")
209
+ log.debug(f"保存后立即读取的面板密码: {test_panel_password}")
210
+ log.debug(f"保存后立即读取的通用密码: {test_password}")
211
+
212
+ # 构建响应消息
213
+ response_data = {
214
+ "message": "配置保存成功",
215
+ "saved_config": {k: v for k, v in new_config.items() if k not in env_locked_keys},
216
+ }
217
+
218
+ return JSONResponse(content=response_data)
219
+
220
+ except HTTPException:
221
+ raise
222
+ except Exception as e:
223
+ log.error(f"保存配置失败: {e}")
224
+ raise HTTPException(status_code=500, detail=str(e))
src/panel/creds.py ADDED
@@ -0,0 +1,1585 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 凭证管理路由模块 - 处理 /creds/* 相关的HTTP请求
3
+ """
4
+
5
+ import asyncio
6
+ import io
7
+ import json
8
+ import os
9
+ import time
10
+ import zipfile
11
+ from typing import Any, List
12
+
13
+ from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, Response
14
+ from fastapi.responses import JSONResponse
15
+
16
+ from log import log
17
+ from src.credential_manager import credential_manager
18
+ from src.models import (
19
+ CredFileActionRequest,
20
+ CredFileBatchActionRequest
21
+ )
22
+ from src.storage_adapter import get_storage_adapter
23
+ from src.utils import verify_panel_token, GEMINICLI_USER_AGENT, ANTIGRAVITY_USER_AGENT
24
+ from src.api.antigravity import fetch_quota_info
25
+ from src.google_oauth_api import Credentials, fetch_project_id_and_tier
26
+ from config import get_code_assist_endpoint
27
+ from .utils import validate_mode
28
+
29
+
30
+ # 创建路由器
31
+ router = APIRouter(prefix="/creds", tags=["credentials"])
32
+
33
+
34
+ # =============================================================================
35
+ # 工具函数 (Helper Functions)
36
+ # =============================================================================
37
+
38
+
39
+ async def extract_json_files_from_zip(zip_file: UploadFile) -> List[dict]:
40
+ """从ZIP文件中提取JSON文件"""
41
+ try:
42
+ # 读取ZIP文件内容
43
+ zip_content = await zip_file.read()
44
+
45
+ # 不限制ZIP文件大小,只在处理时控制文件数量
46
+
47
+ files_data = []
48
+
49
+ with zipfile.ZipFile(io.BytesIO(zip_content), "r") as zip_ref:
50
+ # 获取ZIP中的所有文件
51
+ file_list = zip_ref.namelist()
52
+ json_files = [
53
+ f for f in file_list if f.endswith(".json") and not f.startswith("__MACOSX/")
54
+ ]
55
+
56
+ if not json_files:
57
+ raise HTTPException(status_code=400, detail="ZIP文件中没有找到JSON文件")
58
+
59
+ log.info(f"从ZIP文件 {zip_file.filename} 中找到 {len(json_files)} 个JSON文件")
60
+
61
+ for json_filename in json_files:
62
+ try:
63
+ # 读取JSON文件内容
64
+ with zip_ref.open(json_filename) as json_file:
65
+ content = json_file.read()
66
+
67
+ try:
68
+ content_str = content.decode("utf-8")
69
+ except UnicodeDecodeError:
70
+ log.warning(f"跳过编码错误的文件: {json_filename}")
71
+ continue
72
+
73
+ # 使用原始文件名(去掉路径)
74
+ filename = os.path.basename(json_filename)
75
+ files_data.append({"filename": filename, "content": content_str})
76
+
77
+ except Exception as e:
78
+ log.warning(f"处理ZIP中的文件 {json_filename} 时出错: {e}")
79
+ continue
80
+
81
+ log.info(f"成功从ZIP文件中提取 {len(files_data)} 个有效的JSON文件")
82
+ return files_data
83
+
84
+ except zipfile.BadZipFile:
85
+ raise HTTPException(status_code=400, detail="无效的ZIP文件格式")
86
+ except Exception as e:
87
+ log.error(f"处理ZIP文件失败: {e}")
88
+ raise HTTPException(status_code=500, detail=f"处理ZIP文件失败: {str(e)}")
89
+
90
+
91
+ async def clear_all_model_cooldowns_for_credential(
92
+ storage_adapter: Any,
93
+ filename: str,
94
+ mode: str,
95
+ ) -> None:
96
+ """清空指定凭证的所有模型冷却(后端支持时执行)。"""
97
+ try:
98
+ cleared = await storage_adapter._backend.clear_all_model_cooldowns(filename, mode=mode)
99
+ if not cleared:
100
+ log.warning(f"清空模型CD失败或凭证不存在: {filename} (mode={mode})")
101
+ except Exception as e:
102
+ log.warning(f"清空模型CD时出错: {filename} (mode={mode}), error={e}")
103
+
104
+
105
+ async def upload_credentials_common(
106
+ files: List[UploadFile], mode: str = "geminicli"
107
+ ) -> JSONResponse:
108
+ """批量上传凭证文件的通用函数"""
109
+ mode = validate_mode(mode)
110
+
111
+ if not files:
112
+ raise HTTPException(status_code=400, detail="请选择要上传的文件")
113
+
114
+ # 检查文件数量限制
115
+ if len(files) > 100:
116
+ raise HTTPException(
117
+ status_code=400, detail=f"文件数量过多,最多支持100个文件,当前:{len(files)}个"
118
+ )
119
+
120
+ files_data = []
121
+ for file in files:
122
+ # 检查文件类型:支持JSON和ZIP
123
+ if file.filename.endswith(".zip"):
124
+ zip_files_data = await extract_json_files_from_zip(file)
125
+ files_data.extend(zip_files_data)
126
+ log.info(f"从ZIP文件 {file.filename} 中提取了 {len(zip_files_data)} 个JSON文件")
127
+
128
+ elif file.filename.endswith(".json"):
129
+ # 处理单个JSON文件 - 流式读取
130
+ content_chunks = []
131
+ while True:
132
+ chunk = await file.read(8192)
133
+ if not chunk:
134
+ break
135
+ content_chunks.append(chunk)
136
+
137
+ content = b"".join(content_chunks)
138
+ try:
139
+ content_str = content.decode("utf-8")
140
+ except UnicodeDecodeError:
141
+ raise HTTPException(
142
+ status_code=400, detail=f"文件 {file.filename} 编码格式不支持"
143
+ )
144
+
145
+ files_data.append({"filename": file.filename, "content": content_str})
146
+ else:
147
+ raise HTTPException(
148
+ status_code=400, detail=f"文件 {file.filename} 格式不支持,只支持JSON和ZIP文件"
149
+ )
150
+
151
+
152
+
153
+ batch_size = 1000
154
+ all_results = []
155
+ total_success = 0
156
+
157
+ for i in range(0, len(files_data), batch_size):
158
+ batch_files = files_data[i : i + batch_size]
159
+
160
+ async def process_single_file(file_data):
161
+ try:
162
+ filename = file_data["filename"]
163
+ # 确保文件名只保存basename,避免路径问题
164
+ filename = os.path.basename(filename)
165
+ content_str = file_data["content"]
166
+ credential_data = json.loads(content_str)
167
+
168
+ # 根据凭证类型调用不同的添加方法
169
+ if mode == "antigravity":
170
+ await credential_manager.add_antigravity_credential(filename, credential_data)
171
+ else:
172
+ await credential_manager.add_credential(filename, credential_data)
173
+
174
+ log.debug(f"成功上传 {mode} 凭证文件: {filename}")
175
+ return {"filename": filename, "status": "success", "message": "上传成功"}
176
+
177
+ except json.JSONDecodeError as e:
178
+ return {
179
+ "filename": file_data["filename"],
180
+ "status": "error",
181
+ "message": f"JSON格式错误: {str(e)}",
182
+ }
183
+ except Exception as e:
184
+ return {
185
+ "filename": file_data["filename"],
186
+ "status": "error",
187
+ "message": f"处理失败: {str(e)}",
188
+ }
189
+
190
+ log.info(f"开始并发处理 {len(batch_files)} 个 {mode} 文件...")
191
+ concurrent_tasks = [process_single_file(file_data) for file_data in batch_files]
192
+ batch_results = await asyncio.gather(*concurrent_tasks, return_exceptions=True)
193
+
194
+ processed_results = []
195
+ batch_uploaded_count = 0
196
+ for result in batch_results:
197
+ if isinstance(result, Exception):
198
+ processed_results.append(
199
+ {
200
+ "filename": "unknown",
201
+ "status": "error",
202
+ "message": f"处理异常: {str(result)}",
203
+ }
204
+ )
205
+ else:
206
+ processed_results.append(result)
207
+ if result["status"] == "success":
208
+ batch_uploaded_count += 1
209
+
210
+ all_results.extend(processed_results)
211
+ total_success += batch_uploaded_count
212
+
213
+ batch_num = (i // batch_size) + 1
214
+ total_batches = (len(files_data) + batch_size - 1) // batch_size
215
+ log.info(
216
+ f"批次 {batch_num}/{total_batches} 完成: 成功 "
217
+ f"{batch_uploaded_count}/{len(batch_files)} 个 {mode} 文件"
218
+ )
219
+
220
+ if total_success > 0:
221
+ return JSONResponse(
222
+ content={
223
+ "uploaded_count": total_success,
224
+ "total_count": len(files_data),
225
+ "results": all_results,
226
+ "message": f"批量上传完成: 成功 {total_success}/{len(files_data)} 个 {mode} 文件",
227
+ }
228
+ )
229
+ else:
230
+ raise HTTPException(status_code=400, detail=f"没有 {mode} 文件上传成功")
231
+
232
+
233
+ async def get_creds_status_common(
234
+ offset: int, limit: int, status_filter: str, mode: str = "geminicli",
235
+ error_code_filter: str = None, cooldown_filter: str = None, preview_filter: str = None, tier_filter: str = None
236
+ ) -> JSONResponse:
237
+ """获取凭证文件状态的通用函数"""
238
+ mode = validate_mode(mode)
239
+ # 验证分页参数
240
+ if offset < 0:
241
+ raise HTTPException(status_code=400, detail="offset 必须大于等于 0")
242
+ if limit not in [20, 50, 100, 200, 500, 1000]:
243
+ raise HTTPException(status_code=400, detail="limit 只能是 20、50、100、200、500 或 1000")
244
+ if status_filter not in ["all", "enabled", "disabled"]:
245
+ raise HTTPException(status_code=400, detail="status_filter 只能是 all、enabled 或 disabled")
246
+ if cooldown_filter and cooldown_filter not in ["all", "in_cooldown", "no_cooldown"]:
247
+ raise HTTPException(status_code=400, detail="cooldown_filter 只能是 all、in_cooldown 或 no_cooldown")
248
+ if preview_filter and preview_filter not in ["all", "preview", "no_preview"]:
249
+ raise HTTPException(status_code=400, detail="preview_filter 只能是 all、preview 或 no_preview")
250
+ if tier_filter and tier_filter not in ["all", "free", "pro", "ultra"]:
251
+ raise HTTPException(status_code=400, detail="tier_filter 只能是 all、free、pro 或 ultra")
252
+
253
+
254
+
255
+ storage_adapter = await get_storage_adapter()
256
+ backend_info = await storage_adapter.get_backend_info()
257
+ backend_type = backend_info.get("backend_type", "unknown")
258
+
259
+ # 使用高性能的分页摘要查询
260
+ result = await storage_adapter._backend.get_credentials_summary(
261
+ offset=offset,
262
+ limit=limit,
263
+ status_filter=status_filter,
264
+ mode=mode,
265
+ error_code_filter=error_code_filter if error_code_filter and error_code_filter != "all" else None,
266
+ cooldown_filter=cooldown_filter if cooldown_filter and cooldown_filter != "all" else None,
267
+ preview_filter=preview_filter if preview_filter and preview_filter != "all" else None,
268
+ tier_filter=tier_filter if tier_filter and tier_filter != "all" else None
269
+ )
270
+
271
+ creds_list = []
272
+ for summary in result["items"]:
273
+ cred_info = {
274
+ "filename": os.path.basename(summary["filename"]),
275
+ "user_email": summary["user_email"],
276
+ "disabled": summary["disabled"],
277
+ "error_codes": summary["error_codes"],
278
+ "last_success": summary["last_success"],
279
+ "backend_type": backend_type,
280
+ "model_cooldowns": summary.get("model_cooldowns", {}),
281
+ "tier": summary.get("tier", "pro"),
282
+ }
283
+
284
+ if mode == "geminicli":
285
+ cred_info["preview"] = summary.get("preview", True)
286
+ else:
287
+ cred_info["enable_credit"] = summary.get("enable_credit", False)
288
+
289
+ creds_list.append(cred_info)
290
+
291
+ return JSONResponse(content={
292
+ "items": creds_list,
293
+ "total": result["total"],
294
+ "offset": offset,
295
+ "limit": limit,
296
+ "has_more": (offset + limit) < result["total"],
297
+ "stats": result.get("stats", {"total": 0, "normal": 0, "disabled": 0}),
298
+ })
299
+
300
+
301
+ async def download_all_creds_common(mode: str = "geminicli") -> Response:
302
+ """打包下载所有凭证文件的通用函数"""
303
+ mode = validate_mode(mode)
304
+ zip_filename = "antigravity_credentials.zip" if mode == "antigravity" else "credentials.zip"
305
+
306
+ storage_adapter = await get_storage_adapter()
307
+ credential_filenames = await storage_adapter.list_credentials(mode=mode)
308
+
309
+ if not credential_filenames:
310
+ raise HTTPException(status_code=404, detail=f"没有找到 {mode} 凭证文件")
311
+
312
+ log.info(f"开始打包 {len(credential_filenames)} 个 {mode} 凭证文件...")
313
+
314
+ zip_buffer = io.BytesIO()
315
+
316
+ with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zip_file:
317
+ success_count = 0
318
+ for idx, filename in enumerate(credential_filenames, 1):
319
+ try:
320
+ credential_data = await storage_adapter.get_credential(filename, mode=mode)
321
+ if credential_data:
322
+ content = json.dumps(credential_data, ensure_ascii=False, indent=2)
323
+ zip_file.writestr(os.path.basename(filename), content)
324
+ success_count += 1
325
+
326
+ if idx % 10 == 0:
327
+ log.debug(f"打包进度: {idx}/{len(credential_filenames)}")
328
+
329
+ except Exception as e:
330
+ log.warning(f"处理 {mode} 凭证文件 {filename} 时出错: {e}")
331
+ continue
332
+
333
+ log.info(f"打包完成: 成功 {success_count}/{len(credential_filenames)} 个文件")
334
+
335
+ zip_buffer.seek(0)
336
+ return Response(
337
+ content=zip_buffer.getvalue(),
338
+ media_type="application/zip",
339
+ headers={"Content-Disposition": f"attachment; filename={zip_filename}"},
340
+ )
341
+
342
+
343
+ async def fetch_user_email_common(filename: str, mode: str = "geminicli") -> JSONResponse:
344
+ """获取指定凭证文件用户邮箱的通用函数"""
345
+ mode = validate_mode(mode)
346
+
347
+ filename_only = os.path.basename(filename)
348
+ if not filename_only.endswith(".json"):
349
+ raise HTTPException(status_code=404, detail="无效的文件名")
350
+
351
+ storage_adapter = await get_storage_adapter()
352
+ credential_data = await storage_adapter.get_credential(filename_only, mode=mode)
353
+ if not credential_data:
354
+ raise HTTPException(status_code=404, detail="凭证文件不存在")
355
+
356
+ email = await credential_manager.get_or_fetch_user_email(filename_only, mode=mode)
357
+
358
+ if email:
359
+ return JSONResponse(
360
+ content={
361
+ "filename": filename_only,
362
+ "user_email": email,
363
+ "message": "成功获取用户邮箱",
364
+ }
365
+ )
366
+ else:
367
+ return JSONResponse(
368
+ content={
369
+ "filename": filename_only,
370
+ "user_email": None,
371
+ "message": "无法获取用户邮箱,可能凭证已过期或权限不足",
372
+ },
373
+ status_code=400,
374
+ )
375
+
376
+
377
+ async def refresh_all_user_emails_common(mode: str = "geminicli") -> JSONResponse:
378
+ """刷新所有凭证文件用户邮箱的通用函数 - 只为没有邮箱的凭证获取
379
+
380
+ 利用 get_all_credential_states 批量获取状态
381
+ """
382
+ mode = validate_mode(mode)
383
+
384
+ storage_adapter = await get_storage_adapter()
385
+
386
+ # 一次性批量获取所有凭证的状态
387
+ all_states = await storage_adapter.get_all_credential_states(mode=mode)
388
+
389
+ results = []
390
+ success_count = 0
391
+ skipped_count = 0
392
+
393
+ # 在内存中筛选出需要获取邮箱的凭证
394
+ for filename, state in all_states.items():
395
+ try:
396
+ cached_email = state.get("user_email")
397
+
398
+ if cached_email:
399
+ # 已有邮箱,跳过获取
400
+ skipped_count += 1
401
+ results.append({
402
+ "filename": os.path.basename(filename),
403
+ "user_email": cached_email,
404
+ "success": True,
405
+ "skipped": True,
406
+ })
407
+ continue
408
+
409
+ # 没有邮箱,尝试获取
410
+ email = await credential_manager.get_or_fetch_user_email(filename, mode=mode)
411
+ if email:
412
+ success_count += 1
413
+ results.append({
414
+ "filename": os.path.basename(filename),
415
+ "user_email": email,
416
+ "success": True,
417
+ })
418
+ else:
419
+ results.append({
420
+ "filename": os.path.basename(filename),
421
+ "user_email": None,
422
+ "success": False,
423
+ "error": "无法获取邮箱",
424
+ })
425
+ except Exception as e:
426
+ results.append({
427
+ "filename": os.path.basename(filename),
428
+ "user_email": None,
429
+ "success": False,
430
+ "error": str(e),
431
+ })
432
+
433
+ total_count = len(all_states)
434
+ return JSONResponse(
435
+ content={
436
+ "success_count": success_count,
437
+ "total_count": total_count,
438
+ "skipped_count": skipped_count,
439
+ "results": results,
440
+ "message": f"成功获取 {success_count}/{total_count} 个邮箱地址,跳过 {skipped_count} 个已有邮箱的凭证",
441
+ }
442
+ )
443
+
444
+
445
+ async def deduplicate_credentials_by_email_common(mode: str = "geminicli") -> JSONResponse:
446
+ """批量去重凭证文件的通用函数 - 删除邮箱相同的凭证(只保留一个)"""
447
+ mode = validate_mode(mode)
448
+ storage_adapter = await get_storage_adapter()
449
+
450
+ try:
451
+ duplicate_info = await storage_adapter._backend.get_duplicate_credentials_by_email(
452
+ mode=mode
453
+ )
454
+
455
+ duplicate_groups = duplicate_info.get("duplicate_groups", [])
456
+ no_email_files = duplicate_info.get("no_email_files", [])
457
+ total_count = duplicate_info.get("total_count", 0)
458
+
459
+ if not duplicate_groups:
460
+ return JSONResponse(
461
+ content={
462
+ "deleted_count": 0,
463
+ "kept_count": total_count,
464
+ "total_count": total_count,
465
+ "unique_emails_count": duplicate_info.get("unique_email_count", 0),
466
+ "no_email_count": len(no_email_files),
467
+ "duplicate_groups": [],
468
+ "delete_errors": [],
469
+ "message": "没有发现重复的凭证(相同邮箱)",
470
+ }
471
+ )
472
+
473
+ # 执行删除操作
474
+ deleted_count = 0
475
+ delete_errors = []
476
+ result_duplicate_groups = []
477
+
478
+ for group in duplicate_groups:
479
+ email = group["email"]
480
+ kept_file = group["kept_file"]
481
+ duplicate_files = group["duplicate_files"]
482
+
483
+ deleted_files_in_group = []
484
+ for filename in duplicate_files:
485
+ try:
486
+ success = await credential_manager.remove_credential(filename, mode=mode)
487
+ if success:
488
+ deleted_count += 1
489
+ deleted_files_in_group.append(os.path.basename(filename))
490
+ log.info(f"去重删除凭证: {filename} (邮箱: {email}) (mode={mode})")
491
+ else:
492
+ delete_errors.append(f"{os.path.basename(filename)}: 删除失败")
493
+ except Exception as e:
494
+ delete_errors.append(f"{os.path.basename(filename)}: {str(e)}")
495
+ log.error(f"去重删除凭证 {filename} 时出错: {e}")
496
+
497
+ result_duplicate_groups.append({
498
+ "email": email,
499
+ "kept_file": os.path.basename(kept_file),
500
+ "deleted_files": deleted_files_in_group,
501
+ "duplicate_count": len(deleted_files_in_group),
502
+ })
503
+
504
+ kept_count = total_count - deleted_count
505
+
506
+ return JSONResponse(
507
+ content={
508
+ "deleted_count": deleted_count,
509
+ "kept_count": kept_count,
510
+ "total_count": total_count,
511
+ "unique_emails_count": duplicate_info.get("unique_email_count", 0),
512
+ "no_email_count": len(no_email_files),
513
+ "duplicate_groups": result_duplicate_groups,
514
+ "delete_errors": delete_errors,
515
+ "message": f"去重完成:删除 {deleted_count} 个重复凭证,保留 {kept_count} 个凭证({duplicate_info.get('unique_email_count', 0)} 个唯一邮箱)",
516
+ }
517
+ )
518
+
519
+ except Exception as e:
520
+ log.error(f"批量去重凭证时出错: {e}")
521
+ return JSONResponse(
522
+ status_code=500,
523
+ content={
524
+ "deleted_count": 0,
525
+ "kept_count": 0,
526
+ "total_count": 0,
527
+ "message": f"去重操作失败: {str(e)}",
528
+ }
529
+ )
530
+
531
+
532
+ async def verify_credential_project_common(filename: str, mode: str = "geminicli") -> JSONResponse:
533
+ """验证并重新获取凭证的project id的通用函数"""
534
+ mode = validate_mode(mode)
535
+
536
+ # 验证文件名
537
+ if not filename.endswith(".json"):
538
+ raise HTTPException(status_code=400, detail="无效的文件名")
539
+
540
+
541
+ storage_adapter = await get_storage_adapter()
542
+
543
+ # 获取凭证数据
544
+ credential_data = await storage_adapter.get_credential(filename, mode=mode)
545
+ if not credential_data:
546
+ raise HTTPException(status_code=404, detail="凭证不存在")
547
+
548
+ # 创建凭证对象
549
+ credentials = Credentials.from_dict(credential_data)
550
+
551
+ # 确保token有效(自动刷新)
552
+ token_refreshed = await credentials.refresh_if_needed()
553
+
554
+ # 如果token被刷新了,更新存储
555
+ if token_refreshed:
556
+ log.info(f"Token已自动刷新: {filename} (mode={mode})")
557
+ credential_data = credentials.to_dict()
558
+ await storage_adapter.store_credential(filename, credential_data, mode=mode)
559
+
560
+ # 获取API端点和对应的User-Agent
561
+ if mode == "antigravity":
562
+ api_base_url = await get_code_assist_endpoint()
563
+ user_agent = ANTIGRAVITY_USER_AGENT
564
+ else:
565
+ api_base_url = await get_code_assist_endpoint()
566
+ user_agent = GEMINICLI_USER_AGENT
567
+
568
+ # 重新获取project id(仅 antigravity 模式请求积分)
569
+ if mode == "antigravity":
570
+ project_id, subscription_tier, credit_amount = await fetch_project_id_and_tier(
571
+ access_token=credentials.access_token,
572
+ user_agent=user_agent,
573
+ api_base_url=api_base_url,
574
+ include_credits=True,
575
+ )
576
+ else:
577
+ project_id, subscription_tier = await fetch_project_id_and_tier(
578
+ access_token=credentials.access_token,
579
+ user_agent=user_agent,
580
+ api_base_url=api_base_url,
581
+ )
582
+ credit_amount = None
583
+
584
+ if project_id:
585
+ credential_data["project_id"] = project_id
586
+
587
+ if project_id or subscription_tier:
588
+ await storage_adapter.store_credential(filename, credential_data, mode=mode)
589
+
590
+ # 检验成功后自动解除禁用状态并清除错误码
591
+ state_update = {
592
+ "disabled": False,
593
+ "error_codes": []
594
+ }
595
+
596
+ # 同步更新状态表中的 tier 字段
597
+ state_update["tier"] = subscription_tier
598
+
599
+ # 如果是 geminicli 模式,直接设置 preview=True
600
+ if mode == "geminicli":
601
+ state_update["preview"] = True
602
+
603
+ await storage_adapter.update_credential_state(filename, state_update, mode=mode)
604
+
605
+ log.info(f"检验 {mode} 凭证成功: {filename} - Project ID: {project_id}, Tier: {subscription_tier} - 已解除禁用并清除错误码")
606
+
607
+ response_data = {
608
+ "success": True,
609
+ "filename": filename,
610
+ "project_id": project_id,
611
+ "subscription_tier": subscription_tier,
612
+ "message": "检验成功!Project ID已更新,已解除禁用状态并清除错误码,403错误应该已恢复"
613
+ }
614
+
615
+ if mode == "antigravity" and credit_amount is not None:
616
+ response_data["credit_amount"] = credit_amount
617
+
618
+ return JSONResponse(content=response_data)
619
+ else:
620
+ return JSONResponse(
621
+ status_code=400,
622
+ content={
623
+ "success": False,
624
+ "filename": filename,
625
+ "message": "检验失败:无法获取Project ID,请检查凭证是否有效"
626
+ }
627
+ )
628
+
629
+
630
+ # =============================================================================
631
+ # 路由处理函数 (Route Handlers)
632
+ # =============================================================================
633
+
634
+
635
+ @router.post("/upload")
636
+ async def upload_credentials(
637
+ files: List[UploadFile] = File(...),
638
+ token: str = Depends(verify_panel_token),
639
+ mode: str = "geminicli"
640
+ ):
641
+ """批量上传凭证文件"""
642
+ try:
643
+ mode = validate_mode(mode)
644
+ return await upload_credentials_common(files, mode=mode)
645
+ except HTTPException:
646
+ raise
647
+ except Exception as e:
648
+ log.error(f"批量上传失败: {e}")
649
+ raise HTTPException(status_code=500, detail=str(e))
650
+
651
+
652
+ @router.get("/status")
653
+ async def get_creds_status(
654
+ token: str = Depends(verify_panel_token),
655
+ offset: int = 0,
656
+ limit: int = 50,
657
+ status_filter: str = "all",
658
+ error_code_filter: str = "all",
659
+ cooldown_filter: str = "all",
660
+ preview_filter: str = "all",
661
+ tier_filter: str = "all",
662
+ mode: str = "geminicli"
663
+ ):
664
+ """
665
+ 获取凭证文件的状态(轻量级摘要,不包含完整凭证数据,支持分页和状态筛选)
666
+
667
+ Args:
668
+ offset: 跳过的记录数(默认0)
669
+ limit: 每页返回的记录数(默认50,可选:20, 50, 100, 200, 500, 1000)
670
+ status_filter: 状态筛选(all=全部, enabled=仅启用, disabled=仅禁用)
671
+ error_code_filter: 错误码筛选(all=全部, 或具体错误码如"400", "403")
672
+ cooldown_filter: 冷却状态筛选(all=全部, in_cooldown=冷却中, no_cooldown=未冷却)
673
+ preview_filter: Preview筛选(all=全部, preview=支持preview, no_preview=不支持preview,仅geminicli模式有效)
674
+ tier_filter: tier筛选(all=全部, free/pro/ultra)
675
+ mode: 凭证模式(geminicli 或 antigravity)
676
+
677
+ Returns:
678
+ 包含凭证列表、总数、分页信息的响应
679
+ """
680
+ try:
681
+ mode = validate_mode(mode)
682
+ return await get_creds_status_common(
683
+ offset, limit, status_filter, mode=mode,
684
+ error_code_filter=error_code_filter,
685
+ cooldown_filter=cooldown_filter,
686
+ preview_filter=preview_filter,
687
+ tier_filter=tier_filter
688
+ )
689
+ except HTTPException:
690
+ raise
691
+ except Exception as e:
692
+ log.error(f"获取凭证状态失败: {e}")
693
+ raise HTTPException(status_code=500, detail=str(e))
694
+
695
+
696
+ @router.get("/detail/{filename}")
697
+ async def get_cred_detail(
698
+ filename: str,
699
+ token: str = Depends(verify_panel_token),
700
+ mode: str = "geminicli"
701
+ ):
702
+ """
703
+ 按需获取单个凭证的详细数据(包含完整凭证内容)
704
+ 用于用户查看/编辑凭证详情
705
+ """
706
+ try:
707
+ mode = validate_mode(mode)
708
+ # 验证文件名
709
+ if not filename.endswith(".json"):
710
+ raise HTTPException(status_code=400, detail="无效的文件名")
711
+
712
+
713
+
714
+ storage_adapter = await get_storage_adapter()
715
+ backend_info = await storage_adapter.get_backend_info()
716
+ backend_type = backend_info.get("backend_type", "unknown")
717
+
718
+ # 获取凭证数据
719
+ credential_data = await storage_adapter.get_credential(filename, mode=mode)
720
+ if not credential_data:
721
+ raise HTTPException(status_code=404, detail="凭证不存在")
722
+
723
+ # 获取状态信息
724
+ file_status = await storage_adapter.get_credential_state(filename, mode=mode)
725
+ if not file_status:
726
+ file_status = {
727
+ "error_codes": [],
728
+ "disabled": False,
729
+ "last_success": time.time(),
730
+ "user_email": None,
731
+ }
732
+
733
+ result = {
734
+ "status": file_status,
735
+ "content": credential_data,
736
+ "filename": os.path.basename(filename),
737
+ "backend_type": backend_type,
738
+ "user_email": file_status.get("user_email"),
739
+ "model_cooldowns": file_status.get("model_cooldowns", {}),
740
+ }
741
+
742
+ if mode == "geminicli":
743
+ result["preview"] = file_status.get("preview", True)
744
+ else:
745
+ result["enable_credit"] = file_status.get("enable_credit", False)
746
+
747
+ if backend_type == "file" and os.path.exists(filename):
748
+ result.update({
749
+ "size": os.path.getsize(filename),
750
+ "modified_time": os.path.getmtime(filename),
751
+ })
752
+
753
+ return JSONResponse(content=result)
754
+
755
+ except HTTPException:
756
+ raise
757
+ except Exception as e:
758
+ log.error(f"获取凭证详情失败 {filename}: {e}")
759
+ raise HTTPException(status_code=500, detail=str(e))
760
+
761
+
762
+ @router.post("/action")
763
+ async def creds_action(
764
+ request: CredFileActionRequest,
765
+ token: str = Depends(verify_panel_token),
766
+ mode: str = "geminicli"
767
+ ):
768
+ """对凭证文件执行操作(启用/禁用/删除/enable_credit开关)"""
769
+ try:
770
+ mode = validate_mode(mode)
771
+
772
+ log.info(f"Received request: {request}")
773
+
774
+ filename = request.filename
775
+ action = request.action
776
+
777
+ log.info(f"Performing action '{action}' on file: {filename} (mode={mode})")
778
+
779
+ # 验证文件名
780
+ if not filename.endswith(".json"):
781
+ log.error(f"无效的文件名: {filename}(不是.json文件)")
782
+ raise HTTPException(status_code=400, detail=f"无效的文件名: {filename}")
783
+
784
+ # 获取存储适配器
785
+ storage_adapter = await get_storage_adapter()
786
+
787
+ # 对于删除操作,不需要检查凭证数据是否完整,只需检查条目是否存在
788
+ # 对于其他操作,需要确保凭证数据存在且完整
789
+ if action != "delete":
790
+ # 检查凭证数据是否存在
791
+ credential_data = await storage_adapter.get_credential(filename, mode=mode)
792
+ if not credential_data:
793
+ log.error(f"凭证未找到: {filename} (mode={mode})")
794
+ raise HTTPException(status_code=404, detail="凭证文件不存在")
795
+
796
+ if action == "enable":
797
+ log.info(f"Web请求: 启用文件 {filename} (mode={mode})")
798
+ result = await credential_manager.set_cred_disabled(filename, False, mode=mode)
799
+ log.info(f"[WebRoute] set_cred_disabled 返回结果: {result}")
800
+ if result:
801
+ log.info(f"Web请求: 文件 {filename} 已成功启用 (mode={mode})")
802
+ return JSONResponse(content={"message": f"已启用凭证文件 {os.path.basename(filename)}"})
803
+ else:
804
+ log.error(f"Web请求: 文件 {filename} 启用失败 (mode={mode})")
805
+ raise HTTPException(status_code=500, detail="启用凭证失败,可能凭证不存在")
806
+
807
+ elif action == "disable":
808
+ log.info(f"Web请求: 禁用文件 {filename} (mode={mode})")
809
+ result = await credential_manager.set_cred_disabled(filename, True, mode=mode)
810
+ log.info(f"[WebRoute] set_cred_disabled 返回结果: {result}")
811
+ if result:
812
+ log.info(f"Web请求: 文件 {filename} 已成功禁用 (mode={mode})")
813
+ return JSONResponse(content={"message": f"已禁用凭证文件 {os.path.basename(filename)}"})
814
+ else:
815
+ log.error(f"Web请求: 文件 {filename} 禁用失败 (mode={mode})")
816
+ raise HTTPException(status_code=500, detail="禁用凭证失败,可能凭证不存在")
817
+
818
+ elif action == "delete":
819
+ try:
820
+ # 使用 CredentialManager 删除凭证(包含队列/状态同步)
821
+ success = await credential_manager.remove_credential(filename, mode=mode)
822
+ if success:
823
+ log.info(f"通过管理器成功删除凭证: {filename} (mode={mode})")
824
+ return JSONResponse(
825
+ content={"message": f"已删除凭证文件 {os.path.basename(filename)}"}
826
+ )
827
+ else:
828
+ raise HTTPException(status_code=500, detail="删除凭证失败")
829
+ except Exception as e:
830
+ log.error(f"删除凭证 {filename} 时出错: {e}")
831
+ raise HTTPException(status_code=500, detail=f"删除文件失败: {str(e)}")
832
+
833
+ elif action == "enable_credit":
834
+ if mode != "antigravity":
835
+ raise HTTPException(status_code=400, detail="enable_credit 仅支持 antigravity 模式")
836
+ updated = await storage_adapter.update_credential_state(
837
+ filename, {"enable_credit": True}, mode=mode
838
+ )
839
+ if updated:
840
+ await clear_all_model_cooldowns_for_credential(storage_adapter, filename, mode)
841
+ return JSONResponse(content={"message": f"已开启凭证信用额度模式 {os.path.basename(filename)}"})
842
+ raise HTTPException(status_code=500, detail="开启信用额度模式失败,可能凭证不存在")
843
+
844
+ elif action == "disable_credit":
845
+ if mode != "antigravity":
846
+ raise HTTPException(status_code=400, detail="disable_credit 仅支持 antigravity 模式")
847
+ updated = await storage_adapter.update_credential_state(
848
+ filename, {"enable_credit": False}, mode=mode
849
+ )
850
+ if updated:
851
+ await clear_all_model_cooldowns_for_credential(storage_adapter, filename, mode)
852
+ return JSONResponse(content={"message": f"已关闭凭证信用额度模式 {os.path.basename(filename)}"})
853
+ raise HTTPException(status_code=500, detail="关闭信用额度模式失败,可能凭证不存在")
854
+
855
+ else:
856
+ raise HTTPException(status_code=400, detail="无效的操作类型")
857
+
858
+ except HTTPException:
859
+ raise
860
+ except Exception as e:
861
+ log.error(f"凭证文件操作失败: {e}")
862
+ raise HTTPException(status_code=500, detail=str(e))
863
+
864
+
865
+ @router.post("/batch-action")
866
+ async def creds_batch_action(
867
+ request: CredFileBatchActionRequest,
868
+ token: str = Depends(verify_panel_token),
869
+ mode: str = "geminicli"
870
+ ):
871
+ """批量对凭证文件执行操作(启用/禁用/删除/enable_credit开关)"""
872
+ try:
873
+ mode = validate_mode(mode)
874
+
875
+ action = request.action
876
+ filenames = request.filenames
877
+
878
+ if not filenames:
879
+ raise HTTPException(status_code=400, detail="文件名列表不能为空")
880
+
881
+ log.info(f"对 {len(filenames)} 个文件执行批量操作 '{action}'")
882
+
883
+ success_count = 0
884
+ errors = []
885
+
886
+ storage_adapter = await get_storage_adapter()
887
+
888
+ for filename in filenames:
889
+ try:
890
+ # 验证文件名安全性
891
+ if not filename.endswith(".json"):
892
+ errors.append(f"{filename}: 无效的文件类型")
893
+ continue
894
+
895
+ # 对于删除操作,不���要检查凭证数据完整性
896
+ # 对于其他操作,需要确保凭证数据存在
897
+ if action != "delete":
898
+ credential_data = await storage_adapter.get_credential(filename, mode=mode)
899
+ if not credential_data:
900
+ errors.append(f"{filename}: 凭证不存在")
901
+ continue
902
+
903
+ # 执行相应操作
904
+ if action == "enable":
905
+ await credential_manager.set_cred_disabled(filename, False, mode=mode)
906
+ success_count += 1
907
+
908
+ elif action == "disable":
909
+ await credential_manager.set_cred_disabled(filename, True, mode=mode)
910
+ success_count += 1
911
+
912
+ elif action == "delete":
913
+ try:
914
+ delete_success = await credential_manager.remove_credential(filename, mode=mode)
915
+ if delete_success:
916
+ success_count += 1
917
+ log.info(f"成功删除批量中的凭证: {filename}")
918
+ else:
919
+ errors.append(f"{filename}: 删除失败")
920
+ continue
921
+ except Exception as e:
922
+ errors.append(f"{filename}: 删除文件失败 - {str(e)}")
923
+ continue
924
+ elif action == "enable_credit":
925
+ if mode != "antigravity":
926
+ errors.append(f"{filename}: enable_credit 仅支持 antigravity 模式")
927
+ continue
928
+ updated = await storage_adapter.update_credential_state(
929
+ filename, {"enable_credit": True}, mode=mode
930
+ )
931
+ if updated:
932
+ await clear_all_model_cooldowns_for_credential(storage_adapter, filename, mode)
933
+ success_count += 1
934
+ else:
935
+ errors.append(f"{filename}: 开启信用额度模式失败")
936
+ continue
937
+ elif action == "disable_credit":
938
+ if mode != "antigravity":
939
+ errors.append(f"{filename}: disable_credit 仅支持 antigravity 模式")
940
+ continue
941
+ updated = await storage_adapter.update_credential_state(
942
+ filename, {"enable_credit": False}, mode=mode
943
+ )
944
+ if updated:
945
+ await clear_all_model_cooldowns_for_credential(storage_adapter, filename, mode)
946
+ success_count += 1
947
+ else:
948
+ errors.append(f"{filename}: 关闭信用额度模式失败")
949
+ continue
950
+ else:
951
+ errors.append(f"{filename}: 无效的操作类型")
952
+ continue
953
+
954
+ except Exception as e:
955
+ log.error(f"处理 {filename} 时出错: {e}")
956
+ errors.append(f"{filename}: 处理失败 - {str(e)}")
957
+ continue
958
+
959
+ # 构建返回消息
960
+ result_message = f"批量操作完成:成功处理 {success_count}/{len(filenames)} 个文件"
961
+ if errors:
962
+ result_message += "\n错误详情:\n" + "\n".join(errors)
963
+
964
+ response_data = {
965
+ "success_count": success_count,
966
+ "total_count": len(filenames),
967
+ "errors": errors,
968
+ "message": result_message,
969
+ }
970
+
971
+ return JSONResponse(content=response_data)
972
+
973
+ except HTTPException:
974
+ raise
975
+ except Exception as e:
976
+ log.error(f"批量凭证文件操作失败: {e}")
977
+ raise HTTPException(status_code=500, detail=str(e))
978
+
979
+
980
+ @router.get("/download/{filename}")
981
+ async def download_cred_file(
982
+ filename: str,
983
+ token: str = Depends(verify_panel_token),
984
+ mode: str = "geminicli"
985
+ ):
986
+ """下载单个凭证文件"""
987
+ try:
988
+ mode = validate_mode(mode)
989
+ # 验证文件名安全性
990
+ if not filename.endswith(".json"):
991
+ raise HTTPException(status_code=404, detail="无效的文件名")
992
+
993
+ # 获取存储适配器
994
+ storage_adapter = await get_storage_adapter()
995
+
996
+ # 从存储系统获取凭证数据
997
+ credential_data = await storage_adapter.get_credential(filename, mode=mode)
998
+ if not credential_data:
999
+ raise HTTPException(status_code=404, detail="文件不存在")
1000
+
1001
+ # 转换为JSON字符串
1002
+ content = json.dumps(credential_data, ensure_ascii=False, indent=2)
1003
+
1004
+ from fastapi.responses import Response
1005
+
1006
+ return Response(
1007
+ content=content,
1008
+ media_type="application/json",
1009
+ headers={"Content-Disposition": f"attachment; filename={filename}"},
1010
+ )
1011
+
1012
+ except HTTPException:
1013
+ raise
1014
+ except Exception as e:
1015
+ log.error(f"下载凭证文件失败: {e}")
1016
+ raise HTTPException(status_code=500, detail=str(e))
1017
+
1018
+
1019
+ @router.post("/fetch-email/{filename}")
1020
+ async def fetch_user_email(
1021
+ filename: str,
1022
+ token: str = Depends(verify_panel_token),
1023
+ mode: str = "geminicli"
1024
+ ):
1025
+ """获取指定凭证文件的用户邮箱地址"""
1026
+ try:
1027
+ mode = validate_mode(mode)
1028
+ return await fetch_user_email_common(filename, mode=mode)
1029
+ except HTTPException:
1030
+ raise
1031
+ except Exception as e:
1032
+ log.error(f"获取用户邮箱失败: {e}")
1033
+ raise HTTPException(status_code=500, detail=str(e))
1034
+
1035
+
1036
+ @router.post("/refresh-all-emails")
1037
+ async def refresh_all_user_emails(
1038
+ token: str = Depends(verify_panel_token),
1039
+ mode: str = "geminicli"
1040
+ ):
1041
+ """刷新所有凭证文件的用户邮箱地址"""
1042
+ try:
1043
+ mode = validate_mode(mode)
1044
+ return await refresh_all_user_emails_common(mode=mode)
1045
+ except Exception as e:
1046
+ log.error(f"批量获取用户邮箱失败: {e}")
1047
+ raise HTTPException(status_code=500, detail=str(e))
1048
+
1049
+
1050
+ @router.post("/deduplicate-by-email")
1051
+ async def deduplicate_credentials_by_email(
1052
+ token: str = Depends(verify_panel_token),
1053
+ mode: str = "geminicli"
1054
+ ):
1055
+ """批量去重凭证文件 - 删除邮箱相同的凭证(只保留一个)"""
1056
+ try:
1057
+ mode = validate_mode(mode)
1058
+ return await deduplicate_credentials_by_email_common(mode=mode)
1059
+ except Exception as e:
1060
+ log.error(f"批量去重凭证失败: {e}")
1061
+ raise HTTPException(status_code=500, detail=str(e))
1062
+
1063
+
1064
+ @router.get("/download-all")
1065
+ async def download_all_creds(
1066
+ token: str = Depends(verify_panel_token),
1067
+ mode: str = "geminicli"
1068
+ ):
1069
+ """
1070
+ 打包下载所有凭证文件(流式处理,按需加载每个凭证数据)
1071
+ 只在实际下载时才加载完整凭证内容,最大化性能
1072
+ """
1073
+ try:
1074
+ mode = validate_mode(mode)
1075
+ return await download_all_creds_common(mode=mode)
1076
+ except HTTPException:
1077
+ raise
1078
+ except Exception as e:
1079
+ log.error(f"打包下载失败: {e}")
1080
+ raise HTTPException(status_code=500, detail=str(e))
1081
+
1082
+
1083
+ @router.post("/verify-project/{filename}")
1084
+ async def verify_credential_project(
1085
+ filename: str,
1086
+ token: str = Depends(verify_panel_token),
1087
+ mode: str = "geminicli"
1088
+ ):
1089
+ """
1090
+ 检验凭证的project id,重新获取project id
1091
+ 检验成功可以使403错误恢复
1092
+ """
1093
+ try:
1094
+ mode = validate_mode(mode)
1095
+ return await verify_credential_project_common(filename, mode=mode)
1096
+ except HTTPException:
1097
+ raise
1098
+ except Exception as e:
1099
+ log.error(f"检验凭证Project ID失败 {filename}: {e}")
1100
+ raise HTTPException(status_code=500, detail=f"检验失败: {str(e)}")
1101
+
1102
+
1103
+ @router.get("/errors/{filename}")
1104
+ async def get_credential_errors(
1105
+ filename: str,
1106
+ token: str = Depends(verify_panel_token),
1107
+ mode: str = "geminicli"
1108
+ ):
1109
+ """
1110
+ 获取指定凭证的错误信息(包含 error_codes 和 error_messages)
1111
+
1112
+ Args:
1113
+ filename: 凭证文件名
1114
+ mode: 凭证模式(geminicli 或 antigravity)
1115
+
1116
+ Returns:
1117
+ 包含 error_codes 和 error_messages 的 JSON 响应
1118
+ """
1119
+ try:
1120
+ mode = validate_mode(mode)
1121
+
1122
+ # 验证文件名
1123
+ if not filename.endswith(".json"):
1124
+ raise HTTPException(status_code=400, detail="无效的文件名")
1125
+
1126
+ storage_adapter = await get_storage_adapter()
1127
+
1128
+ # 检查后端是否支持 get_credential_errors 方法
1129
+ if not hasattr(storage_adapter._backend, 'get_credential_errors'):
1130
+ raise HTTPException(
1131
+ status_code=501,
1132
+ detail="当前存储后端不支持获取错误信息"
1133
+ )
1134
+
1135
+ # 获取错误信息
1136
+ error_info = await storage_adapter._backend.get_credential_errors(filename, mode=mode)
1137
+
1138
+ return JSONResponse(content=error_info)
1139
+
1140
+ except HTTPException:
1141
+ raise
1142
+ except Exception as e:
1143
+ log.error(f"获取凭证错误信息失败 {filename}: {e}")
1144
+ raise HTTPException(status_code=500, detail=str(e))
1145
+
1146
+
1147
+ @router.get("/quota/{filename}")
1148
+ async def get_credential_quota(
1149
+ filename: str,
1150
+ token: str = Depends(verify_panel_token),
1151
+ mode: str = "antigravity"
1152
+ ):
1153
+ """
1154
+ 获取指定凭证的额度信息(仅支持 antigravity 模式)
1155
+ """
1156
+ try:
1157
+ mode = validate_mode(mode)
1158
+ # 验证文件名
1159
+ if not filename.endswith(".json"):
1160
+ raise HTTPException(status_code=400, detail="无效的文件名")
1161
+
1162
+
1163
+ storage_adapter = await get_storage_adapter()
1164
+
1165
+ # 获取凭证数据
1166
+ credential_data = await storage_adapter.get_credential(filename, mode=mode)
1167
+ if not credential_data:
1168
+ raise HTTPException(status_code=404, detail="凭证不存在")
1169
+
1170
+ # 使用 Credentials 对象自动处理 token 刷新
1171
+ from src.google_oauth_api import Credentials
1172
+
1173
+ creds = Credentials.from_dict(credential_data)
1174
+
1175
+ # 自动刷新 token(如果需要)
1176
+ await creds.refresh_if_needed()
1177
+
1178
+ # 如果 token 被刷新了,更新存储
1179
+ updated_data = creds.to_dict()
1180
+ if updated_data != credential_data:
1181
+ log.info(f"Token已自动刷新: {filename}")
1182
+ await storage_adapter.store_credential(filename, updated_data, mode=mode)
1183
+ credential_data = updated_data
1184
+
1185
+ # 获取访问令牌
1186
+ access_token = credential_data.get("access_token") or credential_data.get("token")
1187
+ if not access_token:
1188
+ raise HTTPException(status_code=400, detail="凭证中没有访问令牌")
1189
+
1190
+ # 获取额度信息
1191
+ quota_info = await fetch_quota_info(access_token)
1192
+
1193
+ if quota_info.get("success"):
1194
+ return JSONResponse(content={
1195
+ "success": True,
1196
+ "filename": filename,
1197
+ "models": quota_info.get("models", {})
1198
+ })
1199
+ else:
1200
+ return JSONResponse(
1201
+ status_code=400,
1202
+ content={
1203
+ "success": False,
1204
+ "filename": filename,
1205
+ "error": quota_info.get("error", "未知错误")
1206
+ }
1207
+ )
1208
+
1209
+ except HTTPException:
1210
+ raise
1211
+ except Exception as e:
1212
+ log.error(f"获取凭证额度失败 {filename}: {e}")
1213
+ raise HTTPException(status_code=500, detail=f"获取额度失败: {str(e)}")
1214
+
1215
+
1216
+ @router.post("/configure-preview/{filename}")
1217
+ async def configure_preview_channel(
1218
+ filename: str,
1219
+ token: str = Depends(verify_panel_token),
1220
+ mode: str = "geminicli"
1221
+ ):
1222
+ """
1223
+ 为 geminicli 凭证配置 preview 通道
1224
+
1225
+ 通过调用 Google Cloud API 设置 release_channel 为 EXPERIMENTAL
1226
+
1227
+ Args:
1228
+ filename: 凭证文件名
1229
+ mode: 凭证模式(仅支持 geminicli)
1230
+
1231
+ Returns:
1232
+ 配置结果信息
1233
+ """
1234
+ try:
1235
+ mode = validate_mode(mode)
1236
+
1237
+ # 只支持 geminicli 模式
1238
+ if mode != "geminicli":
1239
+ raise HTTPException(
1240
+ status_code=400,
1241
+ detail="配置 preview 通道仅支持 geminicli 模式"
1242
+ )
1243
+
1244
+ # 验证文件名
1245
+ if not filename.endswith(".json"):
1246
+ raise HTTPException(status_code=400, detail="无效的文件名")
1247
+
1248
+ storage_adapter = await get_storage_adapter()
1249
+
1250
+ # 获取凭证数据
1251
+ credential_data = await storage_adapter.get_credential(filename, mode=mode)
1252
+ if not credential_data:
1253
+ raise HTTPException(status_code=404, detail="凭证不存在")
1254
+
1255
+ # 创建凭证对象并刷新 token(如果需要)
1256
+ credentials = Credentials.from_dict(credential_data)
1257
+ token_refreshed = await credentials.refresh_if_needed()
1258
+
1259
+ if token_refreshed:
1260
+ log.info(f"Token已自动刷新: {filename}")
1261
+ credential_data = credentials.to_dict()
1262
+ await storage_adapter.store_credential(filename, credential_data, mode=mode)
1263
+
1264
+ # 获取 access_token 和 project_id
1265
+ access_token = credential_data.get("access_token") or credential_data.get("token")
1266
+ project_id = credential_data.get("project_id", "")
1267
+
1268
+ if not access_token:
1269
+ raise HTTPException(status_code=400, detail="凭证中没有访问令牌")
1270
+ if not project_id:
1271
+ raise HTTPException(status_code=400, detail="凭证中没有项目ID")
1272
+
1273
+ # 调用 Google Cloud API 配置 preview 通道
1274
+ # 根据文档,需要两个步骤:
1275
+ # 1. 创建 Release Channel Setting (EXPERIMENTAL)
1276
+ # 2. 创建 Setting Binding (绑定到目标项目)
1277
+ from src.httpx_client import post_async
1278
+ import uuid
1279
+
1280
+ # 生成唯一的 ID
1281
+ setting_id = f"preview-setting-{uuid.uuid4().hex[:8]}"
1282
+ binding_id = f"preview-binding-{uuid.uuid4().hex[:8]}"
1283
+
1284
+ base_url = f"https://cloudaicompanion.googleapis.com/v1/projects/{project_id}/locations/global"
1285
+ headers = {
1286
+ "Authorization": f"Bearer {access_token}",
1287
+ "Content-Type": "application/json"
1288
+ }
1289
+
1290
+ log.info(f"开始配置 preview 通道: {filename} (project_id={project_id})")
1291
+
1292
+ # 步骤 1: 创建 Release Channel Setting
1293
+ setting_url = f"{base_url}/releaseChannelSettings"
1294
+ setting_response = await post_async(
1295
+ url=setting_url,
1296
+ json={"release_channel": "EXPERIMENTAL"},
1297
+ headers=headers,
1298
+ params={"release_channel_setting_id": setting_id},
1299
+ timeout=30.0
1300
+ )
1301
+
1302
+ setting_status = setting_response.status_code
1303
+
1304
+ if setting_status == 200 or setting_status == 201:
1305
+ log.info(f"步骤 1/2: Release Channel Setting 创建成功 (setting_id={setting_id})")
1306
+ elif setting_status == 409:
1307
+ # Setting 已存在,继续下一步
1308
+ log.info(f"步骤 1/2: Release Channel Setting 已存在")
1309
+ else:
1310
+ # 步骤 1 失败
1311
+ error_text = setting_response.text if hasattr(setting_response, 'text') else ""
1312
+ log.error(f"步骤 1/2 失败: {filename} - Status: {setting_status}, Error: {error_text}")
1313
+
1314
+ return JSONResponse(
1315
+ status_code=setting_status,
1316
+ content={
1317
+ "success": False,
1318
+ "filename": filename,
1319
+ "preview": False,
1320
+ "message": f"创建 Release Channel Setting 失败: HTTP {setting_status}",
1321
+ "error": error_text,
1322
+ "step": "create_setting"
1323
+ }
1324
+ )
1325
+
1326
+ # 步骤 2: 创建 Setting Binding (绑定到当前项目)
1327
+ binding_url = f"{base_url}/releaseChannelSettings/{setting_id}/settingBindings"
1328
+ binding_response = await post_async(
1329
+ url=binding_url,
1330
+ json={
1331
+ "target": f"projects/{project_id}",
1332
+ "product": "GEMINI_CODE_ASSIST"
1333
+ },
1334
+ headers=headers,
1335
+ params={"setting_binding_id": binding_id},
1336
+ timeout=30.0
1337
+ )
1338
+
1339
+ binding_status = binding_response.status_code
1340
+
1341
+ if binding_status == 200 or binding_status == 201:
1342
+ await storage_adapter.update_credential_state(filename, {
1343
+ "preview": True
1344
+ }, mode=mode)
1345
+
1346
+ log.info(f"步骤 2/2: Setting Binding 创建成功 - Preview 通道配置完成: {filename}")
1347
+
1348
+ return JSONResponse(content={
1349
+ "success": True,
1350
+ "filename": filename,
1351
+ "preview": True,
1352
+ "message": "Preview 通道配置成功,已将 preview 属性设置为 true",
1353
+ "setting_id": setting_id,
1354
+ "binding_id": binding_id
1355
+ })
1356
+ elif binding_status == 409:
1357
+ # Binding 已存在,说明已经配置过了
1358
+ await storage_adapter.update_credential_state(filename, {
1359
+ "preview": True
1360
+ }, mode=mode)
1361
+
1362
+ log.info(f"步骤 2/2: Setting Binding 已存在 - Preview 通道已配置: {filename}")
1363
+
1364
+ return JSONResponse(content={
1365
+ "success": True,
1366
+ "filename": filename,
1367
+ "preview": True,
1368
+ "message": "Preview 通道配置已存在,已将 preview 属性设置为 true"
1369
+ })
1370
+ else:
1371
+ # 步骤 2 失败
1372
+ error_text = binding_response.text if hasattr(binding_response, 'text') else ""
1373
+ log.error(f"步骤 2/2 失败: {filename} - Status: {binding_status}, Error: {error_text}")
1374
+
1375
+ return JSONResponse(
1376
+ status_code=binding_status,
1377
+ content={
1378
+ "success": False,
1379
+ "filename": filename,
1380
+ "preview": False,
1381
+ "message": f"创建 Setting Binding 失败: HTTP {binding_status}",
1382
+ "error": error_text,
1383
+ "step": "create_binding"
1384
+ }
1385
+ )
1386
+
1387
+ except HTTPException:
1388
+ raise
1389
+ except Exception as e:
1390
+ log.error(f"配置 preview 通道失败 {filename}: {e}")
1391
+ raise HTTPException(status_code=500, detail=f"配置失败: {str(e)}")
1392
+
1393
+
1394
+ @router.post("/test/{filename}")
1395
+ async def test_credential(
1396
+ filename: str,
1397
+ mode: str = "geminicli",
1398
+ _token: str = Depends(verify_panel_token)
1399
+ ):
1400
+ """
1401
+ 测试指定凭证是否可用
1402
+
1403
+ Args:
1404
+ filename: 凭证文件名
1405
+ mode: 凭证模式(geminicli 或 antigravity)
1406
+
1407
+ Returns:
1408
+ 返回状态码:
1409
+ - 200: 凭证可用
1410
+ - 429: 凭证被限流但有效
1411
+ - 其他: 凭证失败(返回实际错误码)
1412
+ """
1413
+ try:
1414
+ mode = validate_mode(mode)
1415
+
1416
+ # 验证文件名
1417
+ if not filename.endswith(".json"):
1418
+ raise HTTPException(status_code=400, detail="无效的文件名")
1419
+
1420
+ storage_adapter = await get_storage_adapter()
1421
+
1422
+ # 获取凭证数据
1423
+ credential_data = await storage_adapter.get_credential(filename, mode=mode)
1424
+ if not credential_data:
1425
+ raise HTTPException(status_code=404, detail="凭证不存在")
1426
+
1427
+ # 创建凭证对象并尝试刷新 token(如果需要)
1428
+ credentials = Credentials.from_dict(credential_data)
1429
+ token_refreshed = await credentials.refresh_if_needed()
1430
+
1431
+ # 如果 token 被刷新了,更新存储
1432
+ if token_refreshed:
1433
+ log.info(f"Token已自动刷新: {filename} (mode={mode})")
1434
+ credential_data = credentials.to_dict()
1435
+ await storage_adapter.store_credential(filename, credential_data, mode=mode)
1436
+
1437
+ # 获取访问令牌
1438
+ access_token = credential_data.get("access_token") or credential_data.get("token")
1439
+ if not access_token:
1440
+ raise HTTPException(status_code=400, detail="凭证中没有访问令牌")
1441
+
1442
+ # 根据模式构造测试请求
1443
+ from src.httpx_client import post_async
1444
+
1445
+ # 获取 project_id
1446
+ project_id = credential_data.get("project_id", "")
1447
+ if not project_id:
1448
+ raise HTTPException(status_code=400, detail="凭证中没有项目ID")
1449
+
1450
+ # 根据模式选择 API 端点和请求头
1451
+ # 对于 geminicli 模式,使用两次测试:gemini-2.5-flash 和 gemini-3-flash-preview
1452
+ # 对于 antigravity 模式,只使用 gemini-2.5-flash
1453
+ test_model = "gemini-2.5-flash"
1454
+
1455
+ if mode == "antigravity":
1456
+ api_base_url = await get_code_assist_endpoint()
1457
+ from src.api.antigravity import build_antigravity_headers
1458
+ headers = build_antigravity_headers(access_token, test_model)
1459
+ else:
1460
+ api_base_url = await get_code_assist_endpoint()
1461
+ headers = {
1462
+ "Authorization": f"Bearer {access_token}",
1463
+ "Content-Type": "application/json",
1464
+ "User-Agent": GEMINICLI_USER_AGENT,
1465
+ }
1466
+
1467
+ # 第一次测试:使用 gemini-2.5-flash
1468
+ response = await post_async(
1469
+ url=f"{api_base_url}/v1internal:generateContent",
1470
+ json={
1471
+ "model": test_model,
1472
+ "project": project_id,
1473
+ "request": {
1474
+ "contents": [{"role": "user", "parts": [{"text": "hi"}]}],
1475
+ "generationConfig": {"maxOutputTokens": 1}
1476
+ }
1477
+ },
1478
+ headers=headers,
1479
+ timeout=30.0
1480
+ )
1481
+
1482
+ # 返回实际的状态码和详细信息
1483
+ status_code = response.status_code
1484
+
1485
+ if status_code == 200 or status_code == 429:
1486
+ log.info(f"凭证测试成功: {filename} (mode={mode}, model={test_model}, status={status_code})")
1487
+ # 测试成功时清除错误状态
1488
+ if status_code == 200:
1489
+ await storage_adapter.update_credential_state(filename, {
1490
+ "error_codes": [],
1491
+ "error_messages": {}
1492
+ }, mode=mode)
1493
+
1494
+ # 如果是 geminicli 模式且第一次测试成功,继续测试 gemini-3-flash-preview
1495
+ if mode == "geminicli":
1496
+ preview_model = "gemini-3-flash-preview"
1497
+ log.info(f"开始测试 preview 模型: {filename} (model={preview_model})")
1498
+
1499
+ try:
1500
+ preview_response = await post_async(
1501
+ url=f"{api_base_url}/v1internal:generateContent",
1502
+ json={
1503
+ "model": preview_model,
1504
+ "project": project_id,
1505
+ "request": {
1506
+ "contents": [{"role": "user", "parts": [{"text": "hi"}]}],
1507
+ "generationConfig": {"maxOutputTokens": 1}
1508
+ }
1509
+ },
1510
+ headers=headers,
1511
+ timeout=30.0
1512
+ )
1513
+
1514
+ preview_status = preview_response.status_code
1515
+
1516
+ if preview_status == 200 or preview_status == 429:
1517
+ # preview 模型测试成功,设置 preview=True
1518
+ log.info(f"Preview 模型测试成功: {filename} (status={preview_status})")
1519
+ await storage_adapter.update_credential_state(filename, {
1520
+ "preview": True
1521
+ }, mode=mode)
1522
+ elif preview_status == 404:
1523
+ # preview 模型返回 404,说明不支持,设置 preview=False
1524
+ log.warning(f"Preview 模型不支持: {filename} (status=404)")
1525
+ await storage_adapter.update_credential_state(filename, {
1526
+ "preview": False
1527
+ }, mode=mode)
1528
+ else:
1529
+ # 其他错误,保持默认 preview 状态
1530
+ log.warning(f"Preview 模型测试失败: {filename} (status={preview_status})")
1531
+ except Exception as e:
1532
+ log.error(f"Preview 模型测试异常: {filename} - {e}")
1533
+
1534
+ # 返回成功响应
1535
+ return JSONResponse(
1536
+ status_code=status_code,
1537
+ content={
1538
+ "success": True,
1539
+ "status_code": status_code,
1540
+ "message": "测试成功",
1541
+ "filename": filename
1542
+ }
1543
+ )
1544
+ else:
1545
+ log.warning(f"凭证测试失败: {filename} (mode={mode}, status={status_code})")
1546
+ # 测试失败时保存错误码和错误消息(覆盖模式,只保存最新的一个错误)
1547
+ try:
1548
+ error_text = response.text if hasattr(response, 'text') else ""
1549
+
1550
+ # 打印详细错误内容到日志
1551
+ log.error(f"凭证测试错误详情 - 文件: {filename}, 模式: {mode}, 状态码: {status_code}, 错误内容: {error_text}")
1552
+
1553
+ # 使用覆盖模式保存错误(与 credential_manager 保持一致)
1554
+ error_codes = [status_code]
1555
+ error_messages = {str(status_code): error_text if error_text else f"HTTP {status_code}"}
1556
+
1557
+ # 更新状态
1558
+ await storage_adapter.update_credential_state(filename, {
1559
+ "error_codes": error_codes,
1560
+ "error_messages": error_messages
1561
+ }, mode=mode)
1562
+
1563
+ log.info(f"已保存测试错误信息: {filename} - 错误码 {status_code}")
1564
+ except Exception as e:
1565
+ log.error(f"保存测试错误信息失败: {e}")
1566
+
1567
+ # 返回错误响应,包含完整的错误信息
1568
+ error_text = response.text if hasattr(response, 'text') else ""
1569
+
1570
+ return JSONResponse(
1571
+ status_code=status_code,
1572
+ content={
1573
+ "success": False,
1574
+ "status_code": status_code,
1575
+ "message": f"测试失败: HTTP {status_code}",
1576
+ "error": error_text,
1577
+ "filename": filename
1578
+ }
1579
+ )
1580
+
1581
+ except HTTPException:
1582
+ raise
1583
+ except Exception as e:
1584
+ log.error(f"测试凭证失败 {filename}: {e}")
1585
+ raise HTTPException(status_code=500, detail=f"测试失败: {str(e)}")
src/panel/logs.py ADDED
@@ -0,0 +1,237 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 日志路由模块 - 处理 /logs/* 相关的HTTP请求和WebSocket连接
3
+ """
4
+
5
+ import asyncio
6
+ import datetime
7
+ import os
8
+
9
+ from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect
10
+ from fastapi.responses import FileResponse, JSONResponse
11
+ from starlette.websockets import WebSocketState
12
+
13
+ import config
14
+ from log import log
15
+ from src.utils import verify_panel_token
16
+ from .utils import ConnectionManager
17
+
18
+
19
+ # 创建路由器
20
+ router = APIRouter(prefix="/logs", tags=["logs"])
21
+
22
+ # WebSocket连接管理器
23
+ manager = ConnectionManager()
24
+
25
+
26
+ @router.post("/clear")
27
+ async def clear_logs(token: str = Depends(verify_panel_token)):
28
+ """清空日志文件"""
29
+ try:
30
+ # 直接使用环境变量获取日志文件路径
31
+ log_file_path = os.getenv("LOG_FILE", "log.txt")
32
+
33
+ # 检查日志文件是否存在
34
+ if os.path.exists(log_file_path):
35
+ try:
36
+ # 清空文件内容(保留文件),确保以UTF-8编码写入
37
+ # 使用 with 确保文件正确关闭
38
+ with open(log_file_path, "w", encoding="utf-8") as f:
39
+ f.write("")
40
+ f.flush() # 强制刷新到磁盘
41
+ # with 退出时会自动关闭文件
42
+ log.info(f"日志文件已清空: {log_file_path}")
43
+
44
+ # 通知所有WebSocket连接日志已清空
45
+ await manager.broadcast("--- 日志文件已清空 ---")
46
+
47
+ return JSONResponse(
48
+ content={"message": f"日志文件已清空: {os.path.basename(log_file_path)}"}
49
+ )
50
+ except Exception as e:
51
+ log.error(f"清空日志文件失败: {e}")
52
+ raise HTTPException(status_code=500, detail=f"清空日志文件失败: {str(e)}")
53
+ else:
54
+ return JSONResponse(content={"message": "日志文件不存在"})
55
+
56
+ except Exception as e:
57
+ log.error(f"清空日志文件失败: {e}")
58
+ raise HTTPException(status_code=500, detail=f"清空日志文件失败: {str(e)}")
59
+
60
+
61
+ @router.get("/download")
62
+ async def download_logs(token: str = Depends(verify_panel_token)):
63
+ """下载日志文件"""
64
+ try:
65
+ # 直接使用环境变量获取日志文件路径
66
+ log_file_path = os.getenv("LOG_FILE", "log.txt")
67
+
68
+ # 检查日志文件是否存在
69
+ if not os.path.exists(log_file_path):
70
+ raise HTTPException(status_code=404, detail="日志文件不存在")
71
+
72
+ # 检查文件是否为空
73
+ file_size = os.path.getsize(log_file_path)
74
+ if file_size == 0:
75
+ raise HTTPException(status_code=404, detail="日志文件为空")
76
+
77
+ # 生成文件名(包含时间戳)
78
+ timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
79
+ filename = f"gcli2api_logs_{timestamp}.txt"
80
+
81
+ log.info(f"下载日志文件: {log_file_path}")
82
+
83
+ return FileResponse(
84
+ path=log_file_path,
85
+ filename=filename,
86
+ media_type="text/plain",
87
+ headers={"Content-Disposition": f"attachment; filename={filename}"},
88
+ )
89
+
90
+ except HTTPException:
91
+ raise
92
+ except Exception as e:
93
+ log.error(f"下载日志文件失败: {e}")
94
+ raise HTTPException(status_code=500, detail=f"下载日志文件失败: {str(e)}")
95
+
96
+
97
+ @router.websocket("/stream")
98
+ async def websocket_logs(websocket: WebSocket):
99
+ """WebSocket端点,用于实时日志流"""
100
+ # WebSocket 认证: 从查询参数获取 token
101
+ token = websocket.query_params.get("token")
102
+
103
+ if not token:
104
+ await websocket.close(code=403, reason="Missing authentication token")
105
+ log.warning("WebSocket连接被拒绝: 缺少认证token")
106
+ return
107
+
108
+ # 验证 token
109
+ try:
110
+ panel_password = await config.get_panel_password()
111
+ if token != panel_password:
112
+ await websocket.close(code=403, reason="Invalid authentication token")
113
+ log.warning("WebSocket连接被拒绝: token验证失败")
114
+ return
115
+ except Exception as e:
116
+ await websocket.close(code=1011, reason="Authentication error")
117
+ log.error(f"WebSocket认证过程出错: {e}")
118
+ return
119
+
120
+ # 检查连接数限制
121
+ if not await manager.connect(websocket):
122
+ return
123
+
124
+ try:
125
+ # 直接使用环境变量获取日志文件路径
126
+ log_file_path = os.getenv("LOG_FILE", "log.txt")
127
+
128
+ # 发送初始日志(限制为最后50行,减少内存占用)
129
+ if os.path.exists(log_file_path):
130
+ try:
131
+ # 使用 with 确保文件正确关闭
132
+ with open(log_file_path, "r", encoding="utf-8") as f:
133
+ lines = f.readlines()
134
+ # 只发送最后50行,减少初始内存消耗
135
+ for line in lines[-50:]:
136
+ if line.strip():
137
+ await websocket.send_text(line.strip())
138
+ except Exception as e:
139
+ await websocket.send_text(f"Error reading log file: {e}")
140
+ log.error(f"WebSocket初始日志读取错误: {e}")
141
+
142
+ # 监控日志文件变化
143
+ last_size = os.path.getsize(log_file_path) if os.path.exists(log_file_path) else 0
144
+ max_read_size = 8192 # 限制单次读取大小为8KB,防止大量日志造成内存激增
145
+ check_interval = 2 # 增加检查间隔,减少CPU和I/O开销
146
+
147
+ # 创建后台任务监听客户端断开
148
+ # 即使没有日志更新,receive_text() 也能即时感知断开
149
+ async def listen_for_disconnect():
150
+ try:
151
+ while True:
152
+ await websocket.receive_text()
153
+ except Exception:
154
+ pass
155
+
156
+ listener_task = asyncio.create_task(listen_for_disconnect())
157
+
158
+ try:
159
+ while websocket.client_state == WebSocketState.CONNECTED:
160
+ # 使用 asyncio.wait 同时等待定时器和断开信号
161
+ # timeout=check_interval 替代了 asyncio.sleep
162
+ done, pending = await asyncio.wait(
163
+ [listener_task],
164
+ timeout=check_interval,
165
+ return_when=asyncio.FIRST_COMPLETED
166
+ )
167
+
168
+ # 如果监听任务结束(通常是因为连接断开),则退出循环
169
+ if listener_task in done:
170
+ break
171
+
172
+ if os.path.exists(log_file_path):
173
+ current_size = os.path.getsize(log_file_path)
174
+ if current_size > last_size:
175
+ # 限制读取大小,防止单次读取过多内容
176
+ read_size = min(current_size - last_size, max_read_size)
177
+
178
+ try:
179
+ # 使用 with 确保文件正确关闭,即使发生异常
180
+ with open(log_file_path, "r", encoding="utf-8", errors="replace") as f:
181
+ f.seek(last_size)
182
+ new_content = f.read(read_size)
183
+ # with 退出时自动关闭文件句柄
184
+
185
+ # 处理编码错误的情况
186
+ if not new_content:
187
+ last_size = current_size
188
+ continue
189
+
190
+ # 分行发送,避免发送不完整的行
191
+ lines = new_content.splitlines(keepends=True)
192
+ if lines:
193
+ # 如果最后一行没有换行符,保留到下次处理
194
+ if not lines[-1].endswith("\n") and len(lines) > 1:
195
+ # 除了最后一行,其他都发送
196
+ for line in lines[:-1]:
197
+ if line.strip():
198
+ await websocket.send_text(line.rstrip())
199
+ # 更新位置,但要退回最后一行的字节数
200
+ last_size += len(new_content.encode("utf-8")) - len(
201
+ lines[-1].encode("utf-8")
202
+ )
203
+ else:
204
+ # 所有行都发送
205
+ for line in lines:
206
+ if line.strip():
207
+ await websocket.send_text(line.rstrip())
208
+ last_size += len(new_content.encode("utf-8"))
209
+ except UnicodeDecodeError as e:
210
+ # 遇到编码错误时,跳过这部分内容
211
+ log.warning(f"WebSocket日志读取编码错误: {e}, 跳过部分内容")
212
+ last_size = current_size
213
+ except Exception as e:
214
+ await websocket.send_text(f"Error reading new content: {e}")
215
+ # 发生其他错误时,重置文件位置
216
+ last_size = current_size
217
+
218
+ # 如果文件被截断(如清空日志),重置位置
219
+ elif current_size < last_size:
220
+ last_size = 0
221
+ await websocket.send_text("--- 日志已清空 ---")
222
+
223
+ finally:
224
+ # 确保清理监听任务
225
+ if not listener_task.done():
226
+ listener_task.cancel()
227
+ try:
228
+ await listener_task
229
+ except asyncio.CancelledError:
230
+ pass
231
+
232
+ except WebSocketDisconnect:
233
+ pass
234
+ except Exception as e:
235
+ log.error(f"WebSocket logs error: {e}")
236
+ finally:
237
+ manager.disconnect(websocket)
src/panel/root.py ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 根路由模块 - 处理控制面板主页
3
+ """
4
+
5
+ from fastapi import APIRouter, HTTPException, Request
6
+ from fastapi.responses import HTMLResponse
7
+
8
+ from log import log
9
+ from .utils import is_mobile_user_agent
10
+
11
+
12
+ # 创建路由器
13
+ router = APIRouter(tags=["root"])
14
+
15
+
16
+ @router.get("/", response_class=HTMLResponse)
17
+ async def serve_control_panel(request: Request):
18
+ """提供统一控制面板"""
19
+ try:
20
+ user_agent = request.headers.get("user-agent", "")
21
+ is_mobile = is_mobile_user_agent(user_agent)
22
+
23
+ if is_mobile:
24
+ html_file_path = "front/control_panel_mobile.html"
25
+ else:
26
+ html_file_path = "front/control_panel.html"
27
+
28
+ with open(html_file_path, "r", encoding="utf-8") as f:
29
+ html_content = f.read()
30
+ return HTMLResponse(content=html_content)
31
+
32
+ except Exception as e:
33
+ log.error(f"加载控制面板页面失败: {e}")
34
+ raise HTTPException(status_code=500, detail="服务器内部错误")
src/panel/utils.py ADDED
@@ -0,0 +1,166 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 共享工具模块 - 包含WebSocket连接管理、工具函数等
3
+ """
4
+
5
+ import os
6
+ import time
7
+ from collections import deque
8
+ from typing import Set
9
+
10
+ from fastapi import HTTPException, WebSocket
11
+ from starlette.websockets import WebSocketState
12
+
13
+ import config
14
+ from log import log
15
+
16
+
17
+ # =============================================================================
18
+ # WebSocket连接管理
19
+ # =============================================================================
20
+
21
+
22
+ class ConnectionManager:
23
+ def __init__(self, max_connections: int = 3): # 进一步降低最大连接数
24
+ # 使用双端队列严格限制内存使用
25
+ self.active_connections: deque = deque(maxlen=max_connections)
26
+ self.max_connections = max_connections
27
+ self._last_cleanup = 0
28
+ self._cleanup_interval = 120 # 120秒清理一次死连接
29
+
30
+ async def connect(self, websocket: WebSocket):
31
+ # 自动清理死连接
32
+ self._auto_cleanup()
33
+
34
+ # 限制最大连接数,防止内存无限增长
35
+ if len(self.active_connections) >= self.max_connections:
36
+ await websocket.close(code=1008, reason="Too many connections")
37
+ return False
38
+
39
+ await websocket.accept()
40
+ self.active_connections.append(websocket)
41
+ log.debug(f"WebSocket连接建立,当前连接数: {len(self.active_connections)}")
42
+ return True
43
+
44
+ def disconnect(self, websocket: WebSocket):
45
+ # 使用更高效的方式移除连接
46
+ try:
47
+ self.active_connections.remove(websocket)
48
+ except ValueError:
49
+ pass # 连接已不存在
50
+ log.debug(f"WebSocket连接断开,当前连接数: {len(self.active_connections)}")
51
+
52
+ async def send_personal_message(self, message: str, websocket: WebSocket):
53
+ try:
54
+ await websocket.send_text(message)
55
+ except Exception:
56
+ self.disconnect(websocket)
57
+
58
+ async def broadcast(self, message: str):
59
+ # 使用更高效的方式处理广播,避免索引操作
60
+ dead_connections = []
61
+ for conn in self.active_connections:
62
+ try:
63
+ await conn.send_text(message)
64
+ except Exception:
65
+ dead_connections.append(conn)
66
+
67
+ # 批量移除死连接
68
+ for dead_conn in dead_connections:
69
+ self.disconnect(dead_conn)
70
+
71
+ def _auto_cleanup(self):
72
+ """自动清理死连接"""
73
+ current_time = time.time()
74
+ if current_time - self._last_cleanup > self._cleanup_interval:
75
+ self.cleanup_dead_connections()
76
+ self._last_cleanup = current_time
77
+
78
+ def cleanup_dead_connections(self):
79
+ """清理已断开的连接"""
80
+ original_count = len(self.active_connections)
81
+ # 使用列表推导式过滤活跃连接,更高效
82
+ alive_connections = deque(
83
+ [
84
+ conn
85
+ for conn in self.active_connections
86
+ if hasattr(conn, "client_state")
87
+ and conn.client_state != WebSocketState.DISCONNECTED
88
+ ],
89
+ maxlen=self.max_connections,
90
+ )
91
+
92
+ self.active_connections = alive_connections
93
+ cleaned = original_count - len(self.active_connections)
94
+ if cleaned > 0:
95
+ log.debug(f"清理了 {cleaned} 个死连接,剩余连接数: {len(self.active_connections)}")
96
+
97
+
98
+ # =============================================================================
99
+ # 工具函数
100
+ # =============================================================================
101
+
102
+
103
+ def is_mobile_user_agent(user_agent: str) -> bool:
104
+ """检测是否为移动设备用户代理"""
105
+ if not user_agent:
106
+ return False
107
+
108
+ user_agent_lower = user_agent.lower()
109
+ mobile_keywords = [
110
+ "mobile",
111
+ "android",
112
+ "iphone",
113
+ "ipad",
114
+ "ipod",
115
+ "blackberry",
116
+ "windows phone",
117
+ "samsung",
118
+ "htc",
119
+ "motorola",
120
+ "nokia",
121
+ "palm",
122
+ "webos",
123
+ "opera mini",
124
+ "opera mobi",
125
+ "fennec",
126
+ "minimo",
127
+ "symbian",
128
+ "psp",
129
+ "nintendo",
130
+ "tablet",
131
+ ]
132
+
133
+ return any(keyword in user_agent_lower for keyword in mobile_keywords)
134
+
135
+
136
+ def validate_mode(mode: str = "geminicli") -> str:
137
+ """
138
+ 验证 mode 参数
139
+
140
+ Args:
141
+ mode: 模式字符串 ("geminicli" 或 "antigravity")
142
+
143
+ Returns:
144
+ str: 验证后的 mode 字符串
145
+
146
+ Raises:
147
+ HTTPException: 如果 mode 参数无效
148
+ """
149
+ if mode not in ["geminicli", "antigravity"]:
150
+ raise HTTPException(
151
+ status_code=400,
152
+ detail=f"无效的 mode 参数: {mode},只支持 'geminicli' 或 'antigravity'"
153
+ )
154
+ return mode
155
+
156
+
157
+ def get_env_locked_keys() -> Set:
158
+ """获取被环境变量锁定的配置键集合"""
159
+ env_locked_keys = set()
160
+
161
+ # 使用 config.py 中统一维护的映射表
162
+ for env_key, config_key in config.ENV_MAPPINGS.items():
163
+ if os.getenv(env_key):
164
+ env_locked_keys.add(config_key)
165
+
166
+ return env_locked_keys
src/panel/version.py ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 版本信息路由模块 - 处理 /version/* 相关的HTTP请求
3
+ """
4
+
5
+ import os
6
+
7
+ from fastapi import APIRouter
8
+ from fastapi.responses import JSONResponse
9
+
10
+ from log import log
11
+
12
+
13
+ # 创建路由器
14
+ router = APIRouter(prefix="/version", tags=["version"])
15
+
16
+
17
+ @router.get("/info")
18
+ async def get_version_info(check_update: bool = False):
19
+ """
20
+ 获取当前版本信息 - 从version.txt读取
21
+ 可选参数 check_update: 是否检查GitHub上的最新版本
22
+ """
23
+ try:
24
+ # 获取项目根目录
25
+ project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
26
+ version_file = os.path.join(project_root, "version.txt")
27
+
28
+ # 读取version.txt
29
+ if not os.path.exists(version_file):
30
+ return JSONResponse({
31
+ "success": False,
32
+ "error": "version.txt文件不存在"
33
+ })
34
+
35
+ version_data = {}
36
+ with open(version_file, 'r', encoding='utf-8') as f:
37
+ for line in f:
38
+ line = line.strip()
39
+ if '=' in line:
40
+ key, value = line.split('=', 1)
41
+ version_data[key] = value
42
+
43
+ # 检查必要字段
44
+ if 'short_hash' not in version_data:
45
+ return JSONResponse({
46
+ "success": False,
47
+ "error": "version.txt格式错误"
48
+ })
49
+
50
+ response_data = {
51
+ "success": True,
52
+ "version": version_data.get('short_hash', 'unknown'),
53
+ "full_hash": version_data.get('full_hash', ''),
54
+ "message": version_data.get('message', ''),
55
+ "date": version_data.get('date', '')
56
+ }
57
+
58
+ # 如果需要检查更新
59
+ if check_update:
60
+ try:
61
+ from src.httpx_client import get_async
62
+
63
+ # 直接获取GitHub上的version.txt文件
64
+ github_version_url = "https://raw.githubusercontent.com/su-kaka/gcli2api/refs/heads/master/version.txt"
65
+
66
+ # 使用统一的httpx客户端
67
+ resp = await get_async(github_version_url, timeout=10.0)
68
+
69
+ if resp.status_code == 200:
70
+ # 解析远程version.txt
71
+ remote_version_data = {}
72
+ for line in resp.text.strip().split('\n'):
73
+ line = line.strip()
74
+ if '=' in line:
75
+ key, value = line.split('=', 1)
76
+ remote_version_data[key] = value
77
+
78
+ latest_hash = remote_version_data.get('full_hash', '')
79
+ latest_short_hash = remote_version_data.get('short_hash', '')
80
+ current_hash = version_data.get('full_hash', '')
81
+
82
+ has_update = (current_hash != latest_hash) if current_hash and latest_hash else None
83
+
84
+ response_data['check_update'] = True
85
+ response_data['has_update'] = has_update
86
+ response_data['latest_version'] = latest_short_hash
87
+ response_data['latest_hash'] = latest_hash
88
+ response_data['latest_message'] = remote_version_data.get('message', '')
89
+ response_data['latest_date'] = remote_version_data.get('date', '')
90
+ else:
91
+ # GitHub获取失败,但不影响基本版本信息
92
+ response_data['check_update'] = False
93
+ response_data['update_error'] = f"GitHub返回错误: {resp.status_code}"
94
+
95
+ except Exception as e:
96
+ log.debug(f"检查更新失败: {e}")
97
+ response_data['check_update'] = False
98
+ response_data['update_error'] = str(e)
99
+
100
+ return JSONResponse(response_data)
101
+
102
+ except Exception as e:
103
+ log.error(f"获取版本信息失败: {e}")
104
+ return JSONResponse({
105
+ "success": False,
106
+ "error": str(e)
107
+ })