Spaces:
Running
Running
Upload 197 files
Browse filesThis view is limited to 50 files because it contains too many changes.
See raw diff
- .env +60 -0
- .gitattributes +10 -0
- .github/ISSUE_TEMPLATE/bug_report.yml +53 -0
- .github/ISSUE_TEMPLATE/config.yml +1 -0
- .github/ISSUE_TEMPLATE/feature_request.yml +32 -0
- .github/workflows/issues-duplicate.yml +25 -0
- .github/workflows/main.yml +48 -0
- .gitignore +1 -0
- .python-version +1 -0
- Dockerfile +12 -0
- LICENSE +407 -0
- README.md +123 -10
- app/__init__.py +0 -0
- app/api/__init__.py +9 -0
- app/api/dashboard.py +830 -0
- app/api/nonstream_handlers.py +577 -0
- app/api/routes.py +336 -0
- app/api/stream_handlers.py +374 -0
- app/config/__init__.py +4 -0
- app/config/persistence.py +165 -0
- app/config/safety.py +49 -0
- app/config/settings.py +125 -0
- app/main.py +260 -0
- app/models/schemas.py +68 -0
- app/services/OpenAI.py +107 -0
- app/services/__init__.py +9 -0
- app/services/gemini.py +472 -0
- app/templates/__init__.py +1 -0
- app/templates/assets/0506c607efda914c9388132c9cbb0c53.js +0 -0
- app/templates/assets/9a4f356975f1a7b8b7bad9e93c1becba.css +1 -0
- app/templates/assets/aafbaf642c01961ff24ddb8941d1bf59.html +14 -0
- app/templates/assets/favicon.ico +0 -0
- app/templates/index.html +15 -0
- app/utils/__init__.py +12 -0
- app/utils/api_key.py +87 -0
- app/utils/auth.py +36 -0
- app/utils/cache.py +291 -0
- app/utils/error_handling.py +136 -0
- app/utils/logging.py +148 -0
- app/utils/maintenance.py +95 -0
- app/utils/rate_limiting.py +36 -0
- app/utils/request.py +52 -0
- app/utils/response.py +130 -0
- app/utils/stats.py +299 -0
- app/utils/version.py +48 -0
- app/vertex/__init__.py +1 -0
- app/vertex/api_helpers.py +317 -0
- app/vertex/auth.py +109 -0
- app/vertex/config.py +139 -0
- app/vertex/credentials_manager.py +271 -0
.env
ADDED
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
#基础部分
|
2 |
+
#设置一个你自己的访问密码
|
3 |
+
PASSWORD=123
|
4 |
+
#配置时区
|
5 |
+
TZ=Asia/Shanghai
|
6 |
+
|
7 |
+
#ai studio部分
|
8 |
+
|
9 |
+
#将key1,key2,key3等替换为你真正拥有的gemini api key
|
10 |
+
GEMINI_API_KEYS=key1,key2,key3
|
11 |
+
|
12 |
+
#是否启用存储
|
13 |
+
ENABLE_STORAGE=true
|
14 |
+
|
15 |
+
#存储路径
|
16 |
+
STORAGE_DIR=./hajimi
|
17 |
+
|
18 |
+
#每分钟最大请求数
|
19 |
+
MAX_REQUESTS_PER_MINUTE=30
|
20 |
+
|
21 |
+
#每天每个 IP 最大请求数
|
22 |
+
MAX_REQUESTS_PER_DAY_PER_IP=600
|
23 |
+
|
24 |
+
#是否启用假流式传输
|
25 |
+
FAKE_STREAMING=true
|
26 |
+
|
27 |
+
#单api 24小时最大使用次数
|
28 |
+
API_KEY_DAILY_LIMIT=100
|
29 |
+
|
30 |
+
#空响应重试次数
|
31 |
+
MAX_EMPTY_RESPONSES=5
|
32 |
+
|
33 |
+
#是否启用伪装信息
|
34 |
+
RANDOM_STRING=true
|
35 |
+
|
36 |
+
#伪装信息长度
|
37 |
+
RANDOM_STRING_LENGTH=5
|
38 |
+
|
39 |
+
#默认的并发请求数
|
40 |
+
CONCURRENT_REQUESTS=1
|
41 |
+
|
42 |
+
#当请求失败时增加的并发请求数
|
43 |
+
INCREASE_CONCURRENT_ON_FAILURE=0
|
44 |
+
|
45 |
+
允许的最大并发请求数
|
46 |
+
MAX_CONCURRENT_REQUESTS=3
|
47 |
+
|
48 |
+
#是否启用联网模式(联网模式有严格的审核)
|
49 |
+
SEARCH_MODE=false
|
50 |
+
|
51 |
+
#联网模式提示词(用英文单引号包裹提示词)
|
52 |
+
SEARCH_PROMPT='(使用搜索工具联网搜索,需要在content中结合搜索内容)'
|
53 |
+
|
54 |
+
#vertex部分(如果您不需要vertex或不知道vertex是什么,无需配置这些内容)
|
55 |
+
|
56 |
+
#是否启用vertex
|
57 |
+
ENABLE_VERTEX=false
|
58 |
+
|
59 |
+
#vertex ai 凭证
|
60 |
+
GOOGLE_CREDENTIALS_JSON=''
|
.gitattributes
CHANGED
@@ -33,3 +33,13 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
36 |
+
wiki/img/claw/1.png filter=lfs diff=lfs merge=lfs -text
|
37 |
+
wiki/img/claw/3.png filter=lfs diff=lfs merge=lfs -text
|
38 |
+
wiki/img/files.png filter=lfs diff=lfs merge=lfs -text
|
39 |
+
wiki/img/settings.png filter=lfs diff=lfs merge=lfs -text
|
40 |
+
wiki/img/spaces.png filter=lfs diff=lfs merge=lfs -text
|
41 |
+
wiki/img/vertex/index.png filter=lfs diff=lfs merge=lfs -text
|
42 |
+
wiki/img/windows/添加key.png filter=lfs diff=lfs merge=lfs -text
|
43 |
+
wiki/img/windows/主页-安装包位置.png filter=lfs diff=lfs merge=lfs -text
|
44 |
+
wiki/img/zeabur/6.png filter=lfs diff=lfs merge=lfs -text
|
45 |
+
wiki/img/zeabur/7.png filter=lfs diff=lfs merge=lfs -text
|
.github/ISSUE_TEMPLATE/bug_report.yml
ADDED
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
name: "问题反馈"
|
2 |
+
description: Bug report
|
3 |
+
labels: [bug]
|
4 |
+
body:
|
5 |
+
- type: markdown
|
6 |
+
attributes:
|
7 |
+
value: |
|
8 |
+
感谢您花时间填写此错误报告,请**务必确认您的issue不是重复的且不是因为您的操作或版本问题**
|
9 |
+
|
10 |
+
- type: checkboxes
|
11 |
+
attributes:
|
12 |
+
label: Please make sure of the following things
|
13 |
+
description: |
|
14 |
+
您必须勾选以下所有内容,否则您的issue可能会被直接关闭。
|
15 |
+
options:
|
16 |
+
- label: |
|
17 |
+
我已经阅读了[错误自查](./wiki/error.md)。
|
18 |
+
- label: |
|
19 |
+
我确定没有重复的issue或讨论。
|
20 |
+
- label: |
|
21 |
+
我确定是`Hajimi`自身的问题,而不是酒馆等三方件的原因。
|
22 |
+
- label: |
|
23 |
+
我确定这个问题在最新版本中没有被修复。
|
24 |
+
|
25 |
+
- type: input
|
26 |
+
id: version
|
27 |
+
attributes:
|
28 |
+
label: hajimi版本
|
29 |
+
description: |
|
30 |
+
您使用的是哪个版本的程序?请不要使用`latest`作为答案。
|
31 |
+
placeholder: v0.x.x
|
32 |
+
validations:
|
33 |
+
required: true
|
34 |
+
- type: textarea
|
35 |
+
id: bug-description
|
36 |
+
attributes:
|
37 |
+
label: 问题描述
|
38 |
+
validations:
|
39 |
+
required: true
|
40 |
+
- type: textarea
|
41 |
+
id: reproduction
|
42 |
+
attributes:
|
43 |
+
label: 复现方法
|
44 |
+
description: |
|
45 |
+
请提供能复现此问题的方法以方便开发者定问题,请知悉如果不提供它你的issue可能会被直接关闭。
|
46 |
+
validations:
|
47 |
+
required: true
|
48 |
+
- type: textarea
|
49 |
+
id: logs
|
50 |
+
attributes:
|
51 |
+
label: Logs / 日志
|
52 |
+
description: |
|
53 |
+
请复制粘贴错误日志,或者截图
|
.github/ISSUE_TEMPLATE/config.yml
ADDED
@@ -0,0 +1 @@
|
|
|
|
|
1 |
+
blank_issues_enabled: false
|
.github/ISSUE_TEMPLATE/feature_request.yml
ADDED
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
name: "功能请求"
|
2 |
+
description: Feature request
|
3 |
+
labels: [enhancement]
|
4 |
+
body:
|
5 |
+
- type: checkboxes
|
6 |
+
attributes:
|
7 |
+
label: 请确认以下事项
|
8 |
+
description: 您可以选择多项,甚至全部。
|
9 |
+
options:
|
10 |
+
- label: 我已阅读了[更新日志](./update.md)。
|
11 |
+
- label: 我确定没有重复的议题或讨论。
|
12 |
+
- label: 我确定此功能尚未实现。
|
13 |
+
- label: 我确定这是一个合理且普遍的需求。
|
14 |
+
- type: textarea
|
15 |
+
id: feature-description
|
16 |
+
attributes:
|
17 |
+
label: 功能描述
|
18 |
+
validations:
|
19 |
+
required: true
|
20 |
+
- type: textarea
|
21 |
+
id: suggested-solution
|
22 |
+
attributes:
|
23 |
+
label: 建议的解决方案
|
24 |
+
description: |
|
25 |
+
实现此需求的解决思路。
|
26 |
+
- type: textarea
|
27 |
+
id: additional-context
|
28 |
+
attributes:
|
29 |
+
label: 附加信息
|
30 |
+
description: |
|
31 |
+
关于此功能请求的任何其他上下文或截图,或者您觉得有帮助的信息。
|
32 |
+
相关的任何其他上下文或截图,或者你觉得有帮助的信息
|
.github/workflows/issues-duplicate.yml
ADDED
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
name: Issue Duplicate
|
2 |
+
|
3 |
+
on:
|
4 |
+
issues:
|
5 |
+
types: [labeled]
|
6 |
+
|
7 |
+
jobs:
|
8 |
+
create-comment:
|
9 |
+
runs-on: ubuntu-latest
|
10 |
+
if: github.event.label.name == 'duplicate'
|
11 |
+
steps:
|
12 |
+
- name: Create comment
|
13 |
+
uses: actions-cool/issues-helper@v3
|
14 |
+
with:
|
15 |
+
actions: 'create-comment'
|
16 |
+
token: ${{ secrets.GITHUB_TOKEN }}
|
17 |
+
issue-number: ${{ github.event.issue.number }}
|
18 |
+
body: |
|
19 |
+
Hello @${{ github.event.issue.user.login }}, your issue is a duplicate and will be closed.
|
20 |
+
你好 @${{ github.event.issue.user.login }},你的issue是重复的,将被关闭。
|
21 |
+
- name: Close issue
|
22 |
+
uses: actions-cool/issues-helper@v3
|
23 |
+
with:
|
24 |
+
actions: 'close-issue'
|
25 |
+
token: ${{ secrets.GITHUB_TOKEN }}
|
.github/workflows/main.yml
ADDED
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
name: GHCR CI
|
2 |
+
|
3 |
+
on:
|
4 |
+
push:
|
5 |
+
branches: [main]
|
6 |
+
workflow_dispatch:
|
7 |
+
|
8 |
+
jobs:
|
9 |
+
build-and-push:
|
10 |
+
runs-on: ubuntu-latest
|
11 |
+
permissions:
|
12 |
+
contents: read
|
13 |
+
packages: write
|
14 |
+
|
15 |
+
steps:
|
16 |
+
- name: lowercase repository name
|
17 |
+
run: echo "IMAGE_NAME=${GITHUB_REPOSITORY@L}" >> ${GITHUB_ENV}
|
18 |
+
|
19 |
+
- name: Checkout repository
|
20 |
+
uses: actions/checkout@v4
|
21 |
+
|
22 |
+
- name: Read version from file
|
23 |
+
id: version
|
24 |
+
run: echo "VERSION=$(cat version.txt | grep version= | cut -d= -f2)" >> $GITHUB_OUTPUT
|
25 |
+
|
26 |
+
- name: Log in to the GitHub Container Registry
|
27 |
+
uses: docker/login-action@v3
|
28 |
+
with:
|
29 |
+
registry: ghcr.io
|
30 |
+
username: ${{ github.actor }}
|
31 |
+
password: ${{ secrets.GITHUB_TOKEN }}
|
32 |
+
|
33 |
+
- name: Set up Docker Buildx
|
34 |
+
uses: docker/setup-buildx-action@v3
|
35 |
+
|
36 |
+
- name: Build and push Docker image
|
37 |
+
uses: docker/build-push-action@v6
|
38 |
+
with:
|
39 |
+
context: .
|
40 |
+
push: true
|
41 |
+
tags: |
|
42 |
+
ghcr.io/${{ env.IMAGE_NAME }}:latest
|
43 |
+
ghcr.io/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}
|
44 |
+
cache-from: type=gha
|
45 |
+
cache-to: type=gha,mode=max
|
46 |
+
platforms: |
|
47 |
+
linux/amd64
|
48 |
+
linux/arm64
|
.gitignore
ADDED
@@ -0,0 +1 @@
|
|
|
|
|
1 |
+
__pycache__/
|
.python-version
ADDED
@@ -0,0 +1 @@
|
|
|
|
|
1 |
+
3.12
|
Dockerfile
ADDED
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
FROM python:3.12-slim
|
2 |
+
|
3 |
+
WORKDIR /app
|
4 |
+
|
5 |
+
COPY . .
|
6 |
+
|
7 |
+
RUN pip install uv
|
8 |
+
RUN uv pip install --system --no-cache-dir -r requirements.txt
|
9 |
+
|
10 |
+
EXPOSE 7860
|
11 |
+
|
12 |
+
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860"]
|
LICENSE
ADDED
@@ -0,0 +1,407 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
Attribution-NonCommercial 4.0 International
|
2 |
+
|
3 |
+
=======================================================================
|
4 |
+
|
5 |
+
Creative Commons Corporation ("Creative Commons") is not a law firm and
|
6 |
+
does not provide legal services or legal advice. Distribution of
|
7 |
+
Creative Commons public licenses does not create a lawyer-client or
|
8 |
+
other relationship. Creative Commons makes its licenses and related
|
9 |
+
information available on an "as-is" basis. Creative Commons gives no
|
10 |
+
warranties regarding its licenses, any material licensed under their
|
11 |
+
terms and conditions, or any related information. Creative Commons
|
12 |
+
disclaims all liability for damages resulting from their use to the
|
13 |
+
fullest extent possible.
|
14 |
+
|
15 |
+
Using Creative Commons Public Licenses
|
16 |
+
|
17 |
+
Creative Commons public licenses provide a standard set of terms and
|
18 |
+
conditions that creators and other rights holders may use to share
|
19 |
+
original works of authorship and other material subject to copyright
|
20 |
+
and certain other rights specified in the public license below. The
|
21 |
+
following considerations are for informational purposes only, are not
|
22 |
+
exhaustive, and do not form part of our licenses.
|
23 |
+
|
24 |
+
Considerations for licensors: Our public licenses are
|
25 |
+
intended for use by those authorized to give the public
|
26 |
+
permission to use material in ways otherwise restricted by
|
27 |
+
copyright and certain other rights. Our licenses are
|
28 |
+
irrevocable. Licensors should read and understand the terms
|
29 |
+
and conditions of the license they choose before applying it.
|
30 |
+
Licensors should also secure all rights necessary before
|
31 |
+
applying our licenses so that the public can reuse the
|
32 |
+
material as expected. Licensors should clearly mark any
|
33 |
+
material not subject to the license. This includes other CC-
|
34 |
+
licensed material, or material used under an exception or
|
35 |
+
limitation to copyright. More considerations for licensors:
|
36 |
+
wiki.creativecommons.org/Considerations_for_licensors
|
37 |
+
|
38 |
+
Considerations for the public: By using one of our public
|
39 |
+
licenses, a licensor grants the public permission to use the
|
40 |
+
licensed material under specified terms and conditions. If
|
41 |
+
the licensor's permission is not necessary for any reason--for
|
42 |
+
example, because of any applicable exception or limitation to
|
43 |
+
copyright--then that use is not regulated by the license. Our
|
44 |
+
licenses grant only permissions under copyright and certain
|
45 |
+
other rights that a licensor has authority to grant. Use of
|
46 |
+
the licensed material may still be restricted for other
|
47 |
+
reasons, including because others have copyright or other
|
48 |
+
rights in the material. A licensor may make special requests,
|
49 |
+
such as asking that all changes be marked or described.
|
50 |
+
Although not required by our licenses, you are encouraged to
|
51 |
+
respect those requests where reasonable. More considerations
|
52 |
+
for the public:
|
53 |
+
wiki.creativecommons.org/Considerations_for_licensees
|
54 |
+
|
55 |
+
=======================================================================
|
56 |
+
|
57 |
+
Creative Commons Attribution-NonCommercial 4.0 International Public
|
58 |
+
License
|
59 |
+
|
60 |
+
By exercising the Licensed Rights (defined below), You accept and agree
|
61 |
+
to be bound by the terms and conditions of this Creative Commons
|
62 |
+
Attribution-NonCommercial 4.0 International Public License ("Public
|
63 |
+
License"). To the extent this Public License may be interpreted as a
|
64 |
+
contract, You are granted the Licensed Rights in consideration of Your
|
65 |
+
acceptance of these terms and conditions, and the Licensor grants You
|
66 |
+
such rights in consideration of benefits the Licensor receives from
|
67 |
+
making the Licensed Material available under these terms and
|
68 |
+
conditions.
|
69 |
+
|
70 |
+
|
71 |
+
Section 1 -- Definitions.
|
72 |
+
|
73 |
+
a. Adapted Material means material subject to Copyright and Similar
|
74 |
+
Rights that is derived from or based upon the Licensed Material
|
75 |
+
and in which the Licensed Material is translated, altered,
|
76 |
+
arranged, transformed, or otherwise modified in a manner requiring
|
77 |
+
permission under the Copyright and Similar Rights held by the
|
78 |
+
Licensor. For purposes of this Public License, where the Licensed
|
79 |
+
Material is a musical work, performance, or sound recording,
|
80 |
+
Adapted Material is always produced where the Licensed Material is
|
81 |
+
synched in timed relation with a moving image.
|
82 |
+
|
83 |
+
b. Adapter's License means the license You apply to Your Copyright
|
84 |
+
and Similar Rights in Your contributions to Adapted Material in
|
85 |
+
accordance with the terms and conditions of this Public License.
|
86 |
+
|
87 |
+
c. Copyright and Similar Rights means copyright and/or similar rights
|
88 |
+
closely related to copyright including, without limitation,
|
89 |
+
performance, broadcast, sound recording, and Sui Generis Database
|
90 |
+
Rights, without regard to how the rights are labeled or
|
91 |
+
categorized. For purposes of this Public License, the rights
|
92 |
+
specified in Section 2(b)(1)-(2) are not Copyright and Similar
|
93 |
+
Rights.
|
94 |
+
d. Effective Technological Measures means those measures that, in the
|
95 |
+
absence of proper authority, may not be circumvented under laws
|
96 |
+
fulfilling obligations under Article 11 of the WIPO Copyright
|
97 |
+
Treaty adopted on December 20, 1996, and/or similar international
|
98 |
+
agreements.
|
99 |
+
|
100 |
+
e. Exceptions and Limitations means fair use, fair dealing, and/or
|
101 |
+
any other exception or limitation to Copyright and Similar Rights
|
102 |
+
that applies to Your use of the Licensed Material.
|
103 |
+
|
104 |
+
f. Licensed Material means the artistic or literary work, database,
|
105 |
+
or other material to which the Licensor applied this Public
|
106 |
+
License.
|
107 |
+
|
108 |
+
g. Licensed Rights means the rights granted to You subject to the
|
109 |
+
terms and conditions of this Public License, which are limited to
|
110 |
+
all Copyright and Similar Rights that apply to Your use of the
|
111 |
+
Licensed Material and that the Licensor has authority to license.
|
112 |
+
|
113 |
+
h. Licensor means the individual(s) or entity(ies) granting rights
|
114 |
+
under this Public License.
|
115 |
+
|
116 |
+
i. NonCommercial means not primarily intended for or directed towards
|
117 |
+
commercial advantage or monetary compensation. For purposes of
|
118 |
+
this Public License, the exchange of the Licensed Material for
|
119 |
+
other material subject to Copyright and Similar Rights by digital
|
120 |
+
file-sharing or similar means is NonCommercial provided there is
|
121 |
+
no payment of monetary compensation in connection with the
|
122 |
+
exchange.
|
123 |
+
|
124 |
+
j. Share means to provide material to the public by any means or
|
125 |
+
process that requires permission under the Licensed Rights, such
|
126 |
+
as reproduction, public display, public performance, distribution,
|
127 |
+
dissemination, communication, or importation, and to make material
|
128 |
+
available to the public including in ways that members of the
|
129 |
+
public may access the material from a place and at a time
|
130 |
+
individually chosen by them.
|
131 |
+
|
132 |
+
k. Sui Generis Database Rights means rights other than copyright
|
133 |
+
resulting from Directive 96/9/EC of the European Parliament and of
|
134 |
+
the Council of 11 March 1996 on the legal protection of databases,
|
135 |
+
as amended and/or succeeded, as well as other essentially
|
136 |
+
equivalent rights anywhere in the world.
|
137 |
+
|
138 |
+
l. You means the individual or entity exercising the Licensed Rights
|
139 |
+
under this Public License. Your has a corresponding meaning.
|
140 |
+
|
141 |
+
|
142 |
+
Section 2 -- Scope.
|
143 |
+
|
144 |
+
a. License grant.
|
145 |
+
|
146 |
+
1. Subject to the terms and conditions of this Public License,
|
147 |
+
the Licensor hereby grants You a worldwide, royalty-free,
|
148 |
+
non-sublicensable, non-exclusive, irrevocable license to
|
149 |
+
exercise the Licensed Rights in the Licensed Material to:
|
150 |
+
|
151 |
+
a. reproduce and Share the Licensed Material, in whole or
|
152 |
+
in part, for NonCommercial purposes only; and
|
153 |
+
|
154 |
+
b. produce, reproduce, and Share Adapted Material for
|
155 |
+
NonCommercial purposes only.
|
156 |
+
|
157 |
+
2. Exceptions and Limitations. For the avoidance of doubt, where
|
158 |
+
Exceptions and Limitations apply to Your use, this Public
|
159 |
+
License does not apply, and You do not need to comply with
|
160 |
+
its terms and conditions.
|
161 |
+
|
162 |
+
3. Term. The term of this Public License is specified in Section
|
163 |
+
6(a).
|
164 |
+
|
165 |
+
4. Media and formats; technical modifications allowed. The
|
166 |
+
Licensor authorizes You to exercise the Licensed Rights in
|
167 |
+
all media and formats whether now known or hereafter created,
|
168 |
+
and to make technical modifications necessary to do so. The
|
169 |
+
Licensor waives and/or agrees not to assert any right or
|
170 |
+
authority to forbid You from making technical modifications
|
171 |
+
necessary to exercise the Licensed Rights, including
|
172 |
+
technical modifications necessary to circumvent Effective
|
173 |
+
Technological Measures. For purposes of this Public License,
|
174 |
+
simply making modifications authorized by this Section 2(a)
|
175 |
+
(4) never produces Adapted Material.
|
176 |
+
|
177 |
+
5. Downstream recipients.
|
178 |
+
|
179 |
+
a. Offer from the Licensor -- Licensed Material. Every
|
180 |
+
recipient of the Licensed Material automatically
|
181 |
+
receives an offer from the Licensor to exercise the
|
182 |
+
Licensed Rights under the terms and conditions of this
|
183 |
+
Public License.
|
184 |
+
|
185 |
+
b. No downstream restrictions. You may not offer or impose
|
186 |
+
any additional or different terms or conditions on, or
|
187 |
+
apply any Effective Technological Measures to, the
|
188 |
+
Licensed Material if doing so restricts exercise of the
|
189 |
+
Licensed Rights by any recipient of the Licensed
|
190 |
+
Material.
|
191 |
+
|
192 |
+
6. No endorsement. Nothing in this Public License constitutes or
|
193 |
+
may be construed as permission to assert or imply that You
|
194 |
+
are, or that Your use of the Licensed Material is, connected
|
195 |
+
with, or sponsored, endorsed, or granted official status by,
|
196 |
+
the Licensor or others designated to receive attribution as
|
197 |
+
provided in Section 3(a)(1)(A)(i).
|
198 |
+
|
199 |
+
b. Other rights.
|
200 |
+
|
201 |
+
1. Moral rights, such as the right of integrity, are not
|
202 |
+
licensed under this Public License, nor are publicity,
|
203 |
+
privacy, and/or other similar personality rights; however, to
|
204 |
+
the extent possible, the Licensor waives and/or agrees not to
|
205 |
+
assert any such rights held by the Licensor to the limited
|
206 |
+
extent necessary to allow You to exercise the Licensed
|
207 |
+
Rights, but not otherwise.
|
208 |
+
|
209 |
+
2. Patent and trademark rights are not licensed under this
|
210 |
+
Public License.
|
211 |
+
|
212 |
+
3. To the extent possible, the Licensor waives any right to
|
213 |
+
collect royalties from You for the exercise of the Licensed
|
214 |
+
Rights, whether directly or through a collecting society
|
215 |
+
under any voluntary or waivable statutory or compulsory
|
216 |
+
licensing scheme. In all other cases the Licensor expressly
|
217 |
+
reserves any right to collect such royalties, including when
|
218 |
+
the Licensed Material is used other than for NonCommercial
|
219 |
+
purposes.
|
220 |
+
|
221 |
+
|
222 |
+
Section 3 -- License Conditions.
|
223 |
+
|
224 |
+
Your exercise of the Licensed Rights is expressly made subject to the
|
225 |
+
following conditions.
|
226 |
+
|
227 |
+
a. Attribution.
|
228 |
+
|
229 |
+
1. If You Share the Licensed Material (including in modified
|
230 |
+
form), You must:
|
231 |
+
|
232 |
+
a. retain the following if it is supplied by the Licensor
|
233 |
+
with the Licensed Material:
|
234 |
+
|
235 |
+
i. identification of the creator(s) of the Licensed
|
236 |
+
Material and any others designated to receive
|
237 |
+
attribution, in any reasonable manner requested by
|
238 |
+
the Licensor (including by pseudonym if
|
239 |
+
designated);
|
240 |
+
|
241 |
+
ii. a copyright notice;
|
242 |
+
|
243 |
+
iii. a notice that refers to this Public License;
|
244 |
+
|
245 |
+
iv. a notice that refers to the disclaimer of
|
246 |
+
warranties;
|
247 |
+
|
248 |
+
v. a URI or hyperlink to the Licensed Material to the
|
249 |
+
extent reasonably practicable;
|
250 |
+
|
251 |
+
b. indicate if You modified the Licensed Material and
|
252 |
+
retain an indication of any previous modifications; and
|
253 |
+
|
254 |
+
c. indicate the Licensed Material is licensed under this
|
255 |
+
Public License, and include the text of, or the URI or
|
256 |
+
hyperlink to, this Public License.
|
257 |
+
|
258 |
+
2. You may satisfy the conditions in Section 3(a)(1) in any
|
259 |
+
reasonable manner based on the medium, means, and context in
|
260 |
+
which You Share the Licensed Material. For example, it may be
|
261 |
+
reasonable to satisfy the conditions by providing a URI or
|
262 |
+
hyperlink to a resource that includes the required
|
263 |
+
information.
|
264 |
+
|
265 |
+
3. If requested by the Licensor, You must remove any of the
|
266 |
+
information required by Section 3(a)(1)(A) to the extent
|
267 |
+
reasonably practicable.
|
268 |
+
|
269 |
+
4. If You Share Adapted Material You produce, the Adapter's
|
270 |
+
License You apply must not prevent recipients of the Adapted
|
271 |
+
Material from complying with this Public License.
|
272 |
+
|
273 |
+
|
274 |
+
Section 4 -- Sui Generis Database Rights.
|
275 |
+
|
276 |
+
Where the Licensed Rights include Sui Generis Database Rights that
|
277 |
+
apply to Your use of the Licensed Material:
|
278 |
+
|
279 |
+
a. for the avoidance of doubt, Section 2(a)(1) grants You the right
|
280 |
+
to extract, reuse, reproduce, and Share all or a substantial
|
281 |
+
portion of the contents of the database for NonCommercial purposes
|
282 |
+
only;
|
283 |
+
|
284 |
+
b. if You include all or a substantial portion of the database
|
285 |
+
contents in a database in which You have Sui Generis Database
|
286 |
+
Rights, then the database in which You have Sui Generis Database
|
287 |
+
Rights (but not its individual contents) is Adapted Material; and
|
288 |
+
|
289 |
+
c. You must comply with the conditions in Section 3(a) if You Share
|
290 |
+
all or a substantial portion of the contents of the database.
|
291 |
+
|
292 |
+
For the avoidance of doubt, this Section 4 supplements and does not
|
293 |
+
replace Your obligations under this Public License where the Licensed
|
294 |
+
Rights include other Copyright and Similar Rights.
|
295 |
+
|
296 |
+
|
297 |
+
Section 5 -- Disclaimer of Warranties and Limitation of Liability.
|
298 |
+
|
299 |
+
a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE
|
300 |
+
EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS
|
301 |
+
AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF
|
302 |
+
ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS,
|
303 |
+
IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION,
|
304 |
+
WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR
|
305 |
+
PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS,
|
306 |
+
ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT
|
307 |
+
KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT
|
308 |
+
ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU.
|
309 |
+
|
310 |
+
b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE
|
311 |
+
TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION,
|
312 |
+
NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT,
|
313 |
+
INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES,
|
314 |
+
COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR
|
315 |
+
USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN
|
316 |
+
ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR
|
317 |
+
DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT ALLOWED IN FULL OR
|
318 |
+
IN PART, THIS LIMITATION MAY NOT APPLY TO YOU.
|
319 |
+
|
320 |
+
c. The disclaimer of warranties and limitation of liability provided
|
321 |
+
above shall be interpreted in a manner that, to the extent
|
322 |
+
possible, most closely approximates an absolute disclaimer and
|
323 |
+
waiver of all liability.
|
324 |
+
|
325 |
+
|
326 |
+
Section 6 -- Term and Termination.
|
327 |
+
|
328 |
+
a. This Public License applies for the term of the Copyright and
|
329 |
+
Similar Rights licensed here. However, if You fail to comply with
|
330 |
+
this Public License, then Your rights under this Public License
|
331 |
+
terminate automatically.
|
332 |
+
|
333 |
+
b. Where Your right to use the Licensed Material has terminated under
|
334 |
+
Section 6(a), it reinstates:
|
335 |
+
|
336 |
+
1. automatically as of the date the violation is cured, provided
|
337 |
+
it is cured within 30 days of Your discovery of the
|
338 |
+
violation; or
|
339 |
+
|
340 |
+
2. upon express reinstatement by the Licensor.
|
341 |
+
|
342 |
+
For the avoidance of doubt, this Section 6(b) does not affect any
|
343 |
+
right the Licensor may have to seek remedies for Your violations
|
344 |
+
of this Public License.
|
345 |
+
|
346 |
+
c. For the avoidance of doubt, the Licensor may also offer the
|
347 |
+
Licensed Material under separate terms or conditions or stop
|
348 |
+
distributing the Licensed Material at any time; however, doing so
|
349 |
+
will not terminate this Public License.
|
350 |
+
|
351 |
+
d. Sections 1, 5, 6, 7, and 8 survive termination of this Public
|
352 |
+
License.
|
353 |
+
|
354 |
+
|
355 |
+
Section 7 -- Other Terms and Conditions.
|
356 |
+
|
357 |
+
a. The Licensor shall not be bound by any additional or different
|
358 |
+
terms or conditions communicated by You unless expressly agreed.
|
359 |
+
|
360 |
+
b. Any arrangements, understandings, or agreements regarding the
|
361 |
+
Licensed Material not stated herein are separate from and
|
362 |
+
independent of the terms and conditions of this Public License.
|
363 |
+
|
364 |
+
|
365 |
+
Section 8 -- Interpretation.
|
366 |
+
|
367 |
+
a. For the avoidance of doubt, this Public License does not, and
|
368 |
+
shall not be interpreted to, reduce, limit, restrict, or impose
|
369 |
+
conditions on any use of the Licensed Material that could lawfully
|
370 |
+
be made without permission under this Public License.
|
371 |
+
|
372 |
+
b. To the extent possible, if any provision of this Public License is
|
373 |
+
deemed unenforceable, it shall be automatically reformed to the
|
374 |
+
minimum extent necessary to make it enforceable. If the provision
|
375 |
+
cannot be reformed, it shall be severed from this Public License
|
376 |
+
without affecting the enforceability of the remaining terms and
|
377 |
+
conditions.
|
378 |
+
|
379 |
+
c. No term or condition of this Public License will be waived and no
|
380 |
+
failure to comply consented to unless expressly agreed to by the
|
381 |
+
Licensor.
|
382 |
+
|
383 |
+
d. Nothing in this Public License constitutes or may be interpreted
|
384 |
+
as a limitation upon, or waiver of, any privileges and immunities
|
385 |
+
that apply to the Licensor or You, including from the legal
|
386 |
+
processes of any jurisdiction or authority.
|
387 |
+
|
388 |
+
=======================================================================
|
389 |
+
|
390 |
+
Creative Commons is not a party to its public
|
391 |
+
licenses. Notwithstanding, Creative Commons may elect to apply one of
|
392 |
+
its public licenses to material it publishes and in those instances
|
393 |
+
will be considered the “Licensor.” The text of the Creative Commons
|
394 |
+
public licenses is dedicated to the public domain under the CC0 Public
|
395 |
+
Domain Dedication. Except for the limited purpose of indicating that
|
396 |
+
material is shared under a Creative Commons public license or as
|
397 |
+
otherwise permitted by the Creative Commons policies published at
|
398 |
+
creativecommons.org/policies, Creative Commons does not authorize the
|
399 |
+
use of the trademark "Creative Commons" or any other trademark or logo
|
400 |
+
of Creative Commons without its prior written consent including,
|
401 |
+
without limitation, in connection with any unauthorized modifications
|
402 |
+
to any of its public licenses or any other arrangements,
|
403 |
+
understandings, or agreements concerning use of licensed material. For
|
404 |
+
the avoidance of doubt, this paragraph does not form part of the
|
405 |
+
public licenses.
|
406 |
+
|
407 |
+
Creative Commons may be contacted at creativecommons.org.
|
README.md
CHANGED
@@ -1,10 +1,123 @@
|
|
1 |
-
|
2 |
-
|
3 |
-
|
4 |
-
|
5 |
-
|
6 |
-
|
7 |
-
|
8 |
-
|
9 |
-
|
10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# 🚀 HAJIMI Gemini API Proxy
|
2 |
+
|
3 |
+
- 这是一个基于 FastAPI 构建的 Gemini API 代理,旨在提供一个简单、安全且可配置的方式来访问 Google 的 Gemini 模型。适用于在 Hugging Face Spaces 上部署,并支持openai api格式的工具集成。
|
4 |
+
|
5 |
+
## 管理前端一键部署模板
|
6 |
+
[](https://edgeone.ai/pages/new?repository-url=https%3A%2F%2Fgithub.com%2Fwyeeeee%2Fhajimi&root-directory=.%2Fpage&output-directory=..%2Fapp%2Ftemplates%2Fassets&install-command=npm%20install&build-command=npm%20run%20build)
|
7 |
+
|
8 |
+
# 本项目基于CC BY-NC 4.0许可开源,需遵守以下规则
|
9 |
+
- 您必须给出适当的署名,提供指向本协议的链接,并指明是否(对原作)作了修改。您可以以任何合理方式进行,但不得以任何方式暗示许可方认可您或您的使用。
|
10 |
+
- 您不得将本作品用于商业目的,包括但不限于任何形式的商业倒卖、SaaS、API 付费接口、二次销售、打包出售、收费分发或其他直接或间接盈利行为。
|
11 |
+
|
12 |
+
### 如需商业授权,请联系原作者获得书面许可。违者将承担相应法律责任。
|
13 |
+
|
14 |
+
### 感谢[@warming-afternoon](https://github.com/warming-afternoon),[@任梓樂](https://github.com/rzline)在技术上的大力支持
|
15 |
+
|
16 |
+
### 错误自查
|
17 |
+
|
18 |
+
遇到问题请先查看以下的 **错误自查** 文档,确保已尝试按照其上的指示进行了相应的排查与处理。
|
19 |
+
|
20 |
+
- [错误自查](./wiki/error.md)
|
21 |
+
### 使用文档
|
22 |
+
- [huggingface 部署的使用文档(复活?!)(推荐,免费,手机电脑均可使用)](./wiki/huggingface2.md)
|
23 |
+
|
24 |
+
- [Claw Cloud部署的使用文档(推荐,免费,手机电脑均可使用)](./wiki/claw.md) 感谢[@IDeposit](https://github.com/IDeposit)编写
|
25 |
+
|
26 |
+
- [termux部署的使用文档(手机使用)](./wiki/Termux.md) 感谢[@天命不又](https://github.com/tmby)编写
|
27 |
+
|
28 |
+
- [windows 本地部署的使用文档](./wiki/windows.md)
|
29 |
+
|
30 |
+
- ~~[zeabur部署的使用文档(需付费)](./wiki/zeabur.md) 感谢**墨舞ink**编写~~(已过时且暂时无人更新,欢迎提交pull requests)
|
31 |
+
|
32 |
+
- [vertex模式的使用文档](./wiki/vertex.md)
|
33 |
+
|
34 |
+
### 更新日志
|
35 |
+
* v1.0.1
|
36 |
+
* 新增`清除失效密钥`功能
|
37 |
+
* 新增`输出有效秘钥`功能
|
38 |
+
|
39 |
+
## ✨ 主要功能:
|
40 |
+
|
41 |
+
### 🔑 API 密钥轮询和管理
|
42 |
+
|
43 |
+
### 📑 模型列表接口
|
44 |
+
|
45 |
+
### 💬 聊天补全接口:
|
46 |
+
|
47 |
+
* 提供 `/v1/chat/completions` 接口,支持流式和非流式响应,支持函数调用,与 OpenAI API 格式兼容。
|
48 |
+
* 支持的输入内容: 文本、文件、图像
|
49 |
+
* 自动将 OpenAI 格式的请求转换为 Gemini 格式。
|
50 |
+
|
51 |
+
### 🔒 密码保护(可选):
|
52 |
+
|
53 |
+
* 通过 `PASSWORD` 环境变量设置密码。
|
54 |
+
* 提供默认密码 `"123"`。
|
55 |
+
|
56 |
+
### 🧩 服务兼容
|
57 |
+
|
58 |
+
* 提供的接口与 OpenAI API 格式兼容,便于接入各种服务
|
59 |
+
|
60 |
+
### ⚙️ 功能配置
|
61 |
+
|
62 |
+
* 方式 1 : 通过网页前端进行配置
|
63 |
+
* 方式 2 : 根据 [配置文档](./app/config/settings.py) 中的注释说明,修改对应的变量
|
64 |
+
|
65 |
+
## ⚠️ 注意事项:
|
66 |
+
|
67 |
+
* **强烈建议在生产环境中设置 `PASSWORD` 环境变量,并使用强密码。**
|
68 |
+
* 根据你的使用情况调整速率限制相关的环境变量。
|
69 |
+
* 确保你的 Gemini API 密钥具有足够的配额。
|
70 |
+
|
71 |
+
|
72 |
+
## 💡 特色功能:
|
73 |
+
|
74 |
+
### 🎭 假流式传输
|
75 |
+
|
76 |
+
* **作用:** 解决部分网络环境下客户端通过非流式请求 Gemini 时可能遇到的断连问题。**默认开启**。
|
77 |
+
|
78 |
+
* **原理简述:** 当客户端请求流式响应时,本代理会每隔一段时间向客户端发出一个空信息以维持连接,同时在后台向 Gemini 发起一个完整的、非流式的请求。等 Gemini 返回完整响应后,再一次性将响应发回给客户端。
|
79 |
+
|
80 |
+
* **注意:** 如果想使用真的流式请求,请**关闭**该功能
|
81 |
+
|
82 |
+
### ⚡ 并发与缓存
|
83 |
+
|
84 |
+
* **作用:** 允许您为用户的单次提问同时向 Gemini 发送多个请求,并将额外的成功响应缓存起来,用于后续重新生成回复。
|
85 |
+
|
86 |
+
* **注意:** 此功能**默认关闭** 。只有当您将并发数设置为 2 或以上时,缓存才会生效。缓存匹配要求提问的上下文与被缓存的问题**完全一致**(包括标点符号)。此外,该模式目前仅支持非流式及假流式传输
|
87 |
+
|
88 |
+
**Q: 新版本增加的并发缓存功能会增加 gemini 配额的使用量吗?**
|
89 |
+
|
90 |
+
**A: 不会**。因为默认情况下该功能是关闭的。只有当你主动将并发数 `CONCURRENT_REQUESTS` 设置为大于 1 的数值时,才会实际发起并发请求,这才会消耗更多配额。
|
91 |
+
|
92 |
+
**Q: 如何使用并发缓存功能?**
|
93 |
+
|
94 |
+
**A:** 修改并发请求数,使其等于你想在一次用户提��中同时向 Gemini 发送的请求数量(例如设置为 `3`)。
|
95 |
+
|
96 |
+
这样设置后,如果一次并发请求中收到了多个成功的响应,除了第一个返回给用户外,其他的就会被缓存起来。
|
97 |
+
|
98 |
+
### 🎭 伪装信息
|
99 |
+
|
100 |
+
* **作用:** 在发送给 Gemini 的消息中添加一段随机生成的、无意义的字符串,用于“伪装”请求,可能有助于防止被识别为自动化程序。**默认开启**。
|
101 |
+
|
102 |
+
* **注意:** 如果使用非 SillyTavern 的其余客户端 (例如 cherryStudio ),请**关闭**该功能
|
103 |
+
|
104 |
+
### 🌐 联网模式
|
105 |
+
|
106 |
+
* **作用:** 让 Gemini 模型能够利用搜索工具进行联网搜索,以回答需要最新信息或超出其知识库范围的问题。
|
107 |
+
|
108 |
+
* **如何使用:**
|
109 |
+
|
110 |
+
在客户端请求时,选择模型名称带有 `-search` 后缀的模型(例如 `gemini-2.5-pro-search`,具体可用模型请通过 `/v1/models` 接口查询)。
|
111 |
+
|
112 |
+
|
113 |
+
### 🚦 速率限制和防滥用:
|
114 |
+
|
115 |
+
* 通过环境变量自定义限制:
|
116 |
+
* `MAX_REQUESTS_PER_MINUTE`:每分钟最大请求数(默认 30)。
|
117 |
+
* `MAX_REQUESTS_PER_DAY_PER_IP`:每天每个 IP 最大请求数(默认 600)。
|
118 |
+
* 超过速率限制时返回 429 错误。
|
119 |
+
|
120 |
+
# 赞助商
|
121 |
+
[](https://edgeone.ai)
|
122 |
+
CDN acceleration and security protection for this project are sponsored by Tencent EdgeOne.
|
123 |
+
本项目的CDN加速及安全防护由腾讯EdgeOne赞助支持。
|
app/__init__.py
ADDED
File without changes
|
app/api/__init__.py
ADDED
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from app.api.routes import router, init_router
|
2 |
+
from app.api.dashboard import dashboard_router, init_dashboard_router
|
3 |
+
|
4 |
+
__all__ = [
|
5 |
+
'router',
|
6 |
+
'init_router',
|
7 |
+
'dashboard_router',
|
8 |
+
'init_dashboard_router'
|
9 |
+
]
|
app/api/dashboard.py
ADDED
@@ -0,0 +1,830 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from fastapi import APIRouter, HTTPException, Depends
|
2 |
+
from datetime import datetime, timedelta
|
3 |
+
import time
|
4 |
+
import asyncio
|
5 |
+
import random
|
6 |
+
import threading
|
7 |
+
from app.utils import (
|
8 |
+
log_manager,
|
9 |
+
ResponseCacheManager,
|
10 |
+
ActiveRequestsManager,
|
11 |
+
clean_expired_stats
|
12 |
+
)
|
13 |
+
import app.config.settings as settings
|
14 |
+
import app.vertex.config as app_config
|
15 |
+
from app.services import GeminiClient
|
16 |
+
from app.utils.auth import verify_web_password
|
17 |
+
from app.utils.maintenance import api_call_stats_clean
|
18 |
+
from app.utils.logging import log, vertex_log_manager
|
19 |
+
from app.config.persistence import save_settings
|
20 |
+
from app.utils.stats import api_stats_manager
|
21 |
+
from typing import List
|
22 |
+
import json
|
23 |
+
|
24 |
+
# Import necessary components for Google Credentials JSON update
|
25 |
+
from app.vertex.credentials_manager import CredentialManager, parse_multiple_json_credentials
|
26 |
+
|
27 |
+
# 引入重新初始化vertex的函数
|
28 |
+
from app.vertex.vertex_ai_init import init_vertex_ai as re_init_vertex_ai_function, reset_global_fallback_client
|
29 |
+
|
30 |
+
# 创建路由器
|
31 |
+
dashboard_router = APIRouter(prefix="/api", tags=["dashboard"])
|
32 |
+
|
33 |
+
# 全局变量引用,将在init_dashboard_router中设置
|
34 |
+
key_manager = None
|
35 |
+
response_cache_manager = None
|
36 |
+
active_requests_manager = None
|
37 |
+
credential_manager = None # 添加全局credential_manager变量
|
38 |
+
|
39 |
+
# 用于存储API密钥检测的进度信息
|
40 |
+
api_key_test_progress = {
|
41 |
+
"is_running": False,
|
42 |
+
"completed": 0,
|
43 |
+
"total": 0,
|
44 |
+
"valid": 0,
|
45 |
+
"invalid": 0,
|
46 |
+
"is_completed": False
|
47 |
+
}
|
48 |
+
|
49 |
+
def init_dashboard_router(
|
50 |
+
key_mgr,
|
51 |
+
cache_mgr,
|
52 |
+
active_req_mgr,
|
53 |
+
cred_mgr=None # 添加credential_manager参数
|
54 |
+
):
|
55 |
+
"""初始化仪表盘路由器"""
|
56 |
+
global key_manager, response_cache_manager, active_requests_manager, credential_manager
|
57 |
+
key_manager = key_mgr
|
58 |
+
response_cache_manager = cache_mgr
|
59 |
+
active_requests_manager = active_req_mgr
|
60 |
+
credential_manager = cred_mgr # 保存credential_manager
|
61 |
+
return dashboard_router
|
62 |
+
|
63 |
+
async def run_blocking_init_vertex():
|
64 |
+
"""Helper to run the init_vertex_ai function with the current credential_manager."""
|
65 |
+
try:
|
66 |
+
if credential_manager is None:
|
67 |
+
# 如果credential_manager为None,记录警告并创建一个新的实例
|
68 |
+
log('warning', "Credential Manager不存在,将创建一个新的实例用于初始化")
|
69 |
+
temp_credential_manager = CredentialManager()
|
70 |
+
credentials_count = temp_credential_manager.get_total_credentials()
|
71 |
+
log('info', f"临时Credential Manager已创建,包含{credentials_count}个凭证")
|
72 |
+
|
73 |
+
# 传递临时创建的credential_manager实例
|
74 |
+
success = await re_init_vertex_ai_function(credential_manager=temp_credential_manager)
|
75 |
+
else:
|
76 |
+
# 记录当前有多少凭证可用
|
77 |
+
credentials_count = credential_manager.get_total_credentials()
|
78 |
+
log('info', f"使用现有Credential Manager进行初始化,当前有{credentials_count}个凭证")
|
79 |
+
|
80 |
+
# 传递当前的credential_manager实例
|
81 |
+
success = await re_init_vertex_ai_function(credential_manager=credential_manager)
|
82 |
+
|
83 |
+
if success:
|
84 |
+
log('info', "异步重新执行 init_vertex_ai 成功,以响应 Google Credentials JSON 的更新。")
|
85 |
+
else:
|
86 |
+
log('warning', "异步重新执行 init_vertex_ai 失败或未完成,在 Google Credentials JSON 更新后。")
|
87 |
+
except Exception as e:
|
88 |
+
log('error', f"执行 run_blocking_init_vertex 时出错: {e}")
|
89 |
+
|
90 |
+
@dashboard_router.get("/dashboard-data")
|
91 |
+
async def get_dashboard_data():
|
92 |
+
"""获取仪表盘数据的API端点,用于动态刷新"""
|
93 |
+
# 先清理过期数据,确保统计数据是最新的
|
94 |
+
await api_stats_manager.maybe_cleanup()
|
95 |
+
await response_cache_manager.clean_expired() # 使用管理器清理缓存
|
96 |
+
active_requests_manager.clean_completed() # 使用管理器清理活跃请求
|
97 |
+
|
98 |
+
# 获取当前统计数据
|
99 |
+
now = datetime.now()
|
100 |
+
|
101 |
+
# 使用新的统计系统获取调用数据
|
102 |
+
last_24h_calls = api_stats_manager.get_calls_last_24h()
|
103 |
+
hourly_calls = api_stats_manager.get_calls_last_hour(now)
|
104 |
+
minute_calls = api_stats_manager.get_calls_last_minute(now)
|
105 |
+
|
106 |
+
# 获取时间序列数据
|
107 |
+
time_series_data, tokens_time_series = api_stats_manager.get_time_series_data(30, now)
|
108 |
+
|
109 |
+
# 获取API密钥使用统计
|
110 |
+
api_key_stats = api_stats_manager.get_api_key_stats(key_manager.api_keys)
|
111 |
+
|
112 |
+
# 根据ENABLE_VERTEX设置决定返回哪种日志
|
113 |
+
if settings.ENABLE_VERTEX:
|
114 |
+
recent_logs = vertex_log_manager.get_recent_logs(500) # 获取最近500条Vertex日志
|
115 |
+
else:
|
116 |
+
recent_logs = log_manager.get_recent_logs(500) # 获取最近500条普通日志
|
117 |
+
|
118 |
+
# 获取缓存统计
|
119 |
+
total_cache = response_cache_manager.cur_cache_num
|
120 |
+
|
121 |
+
# 获取活跃请求统计
|
122 |
+
active_count = len(active_requests_manager.active_requests)
|
123 |
+
active_done = sum(1 for task in active_requests_manager.active_requests.values() if task.done())
|
124 |
+
active_pending = active_count - active_done
|
125 |
+
|
126 |
+
# 获取凭证数量
|
127 |
+
credentials_count = 0
|
128 |
+
if credential_manager is not None:
|
129 |
+
credentials_count = credential_manager.get_total_credentials()
|
130 |
+
|
131 |
+
# 返回JSON格式的数据
|
132 |
+
return {
|
133 |
+
"key_count": len(key_manager.api_keys),
|
134 |
+
"model_count": len(GeminiClient.AVAILABLE_MODELS),
|
135 |
+
"retry_count": settings.MAX_RETRY_NUM,
|
136 |
+
"credentials_count": credentials_count, # 添加凭证数量
|
137 |
+
"last_24h_calls": last_24h_calls,
|
138 |
+
"hourly_calls": hourly_calls,
|
139 |
+
"minute_calls": minute_calls,
|
140 |
+
"calls_time_series": time_series_data, # 添加API调用时间序列
|
141 |
+
"tokens_time_series": tokens_time_series, # 添加Token使用时间序列
|
142 |
+
"current_time": datetime.now().strftime('%H:%M:%S'),
|
143 |
+
"logs": recent_logs,
|
144 |
+
"api_key_stats": api_key_stats,
|
145 |
+
# 添加配置信息
|
146 |
+
"max_requests_per_minute": settings.MAX_REQUESTS_PER_MINUTE,
|
147 |
+
"max_requests_per_day_per_ip": settings.MAX_REQUESTS_PER_DAY_PER_IP,
|
148 |
+
# 添加版本信息
|
149 |
+
"local_version": settings.version["local_version"],
|
150 |
+
"remote_version": settings.version["remote_version"],
|
151 |
+
"has_update": settings.version["has_update"],
|
152 |
+
# 添加流式响应配置
|
153 |
+
"fake_streaming": settings.FAKE_STREAMING,
|
154 |
+
"fake_streaming_interval": settings.FAKE_STREAMING_INTERVAL,
|
155 |
+
# 添加随机字符串配置
|
156 |
+
"random_string": settings.RANDOM_STRING,
|
157 |
+
"random_string_length": settings.RANDOM_STRING_LENGTH,
|
158 |
+
# 添加联网搜索配置
|
159 |
+
"search_mode": settings.search["search_mode"],
|
160 |
+
"search_prompt": settings.search["search_prompt"],
|
161 |
+
# 添加缓存信息
|
162 |
+
"cache_entries": total_cache,
|
163 |
+
"cache_expiry_time": settings.CACHE_EXPIRY_TIME,
|
164 |
+
"max_cache_entries": settings.MAX_CACHE_ENTRIES,
|
165 |
+
# 添加活跃请求池信息
|
166 |
+
"active_count": active_count,
|
167 |
+
"active_done": active_done,
|
168 |
+
"active_pending": active_pending,
|
169 |
+
# 添加并发请求配置
|
170 |
+
"concurrent_requests": settings.CONCURRENT_REQUESTS,
|
171 |
+
"increase_concurrent_on_failure": settings.INCREASE_CONCURRENT_ON_FAILURE,
|
172 |
+
"max_concurrent_requests": settings.MAX_CONCURRENT_REQUESTS,
|
173 |
+
# 启用vertex
|
174 |
+
"enable_vertex": settings.ENABLE_VERTEX,
|
175 |
+
# 添加Vertex Express配置
|
176 |
+
"enable_vertex_express": settings.ENABLE_VERTEX_EXPRESS,
|
177 |
+
"vertex_express_api_key": bool(settings.VERTEX_EXPRESS_API_KEY), # 只返回是否设置的状态
|
178 |
+
"google_credentials_json": bool(settings.GOOGLE_CREDENTIALS_JSON), # 只返回是否设置的状态
|
179 |
+
# 添加最大重试次数
|
180 |
+
"max_retry_num": settings.MAX_RETRY_NUM,
|
181 |
+
# 添加空响应重试次数限制
|
182 |
+
"max_empty_responses": settings.MAX_EMPTY_RESPONSES,
|
183 |
+
}
|
184 |
+
|
185 |
+
@dashboard_router.post("/reset-stats")
|
186 |
+
async def reset_stats(password_data: dict):
|
187 |
+
"""
|
188 |
+
重置API调用统计数据
|
189 |
+
|
190 |
+
Args:
|
191 |
+
password_data (dict): 包含密码的字典
|
192 |
+
|
193 |
+
Returns:
|
194 |
+
dict: 操作结果
|
195 |
+
"""
|
196 |
+
try:
|
197 |
+
if not isinstance(password_data, dict):
|
198 |
+
raise HTTPException(status_code=422, detail="请求体格式错误:应为JSON对象")
|
199 |
+
|
200 |
+
password = password_data.get("password")
|
201 |
+
if not password:
|
202 |
+
raise HTTPException(status_code=400, detail="缺少密码参数")
|
203 |
+
|
204 |
+
if not isinstance(password, str):
|
205 |
+
raise HTTPException(status_code=422, detail="密码参数类型错误:应为字符串")
|
206 |
+
|
207 |
+
if not verify_web_password(password):
|
208 |
+
raise HTTPException(status_code=401, detail="密码错误")
|
209 |
+
|
210 |
+
# 调用重置函数
|
211 |
+
await api_stats_manager.reset()
|
212 |
+
|
213 |
+
return {"status": "success", "message": "API调用统计数据已重置"}
|
214 |
+
except HTTPException:
|
215 |
+
raise
|
216 |
+
except Exception as e:
|
217 |
+
raise HTTPException(status_code=500, detail=f"重置失败:{str(e)}")
|
218 |
+
|
219 |
+
@dashboard_router.post("/update-config")
|
220 |
+
async def update_config(config_data: dict):
|
221 |
+
"""
|
222 |
+
更新配置项
|
223 |
+
|
224 |
+
Args:
|
225 |
+
config_data (dict): 包含配置项和密码的字典
|
226 |
+
|
227 |
+
Returns:
|
228 |
+
dict: 操作结果
|
229 |
+
"""
|
230 |
+
try:
|
231 |
+
if not isinstance(config_data, dict):
|
232 |
+
raise HTTPException(status_code=422, detail="请求体格式错误:应为JSON对象")
|
233 |
+
|
234 |
+
password = config_data.get("password")
|
235 |
+
if not password:
|
236 |
+
raise HTTPException(status_code=400, detail="缺少密码参数")
|
237 |
+
|
238 |
+
if not isinstance(password, str):
|
239 |
+
raise HTTPException(status_code=422, detail="密码参数类型错误:应为字符串")
|
240 |
+
|
241 |
+
if not verify_web_password(password):
|
242 |
+
raise HTTPException(status_code=401, detail="密码错误")
|
243 |
+
|
244 |
+
# 获取要更新的配置项
|
245 |
+
config_key = config_data.get("key")
|
246 |
+
config_value = config_data.get("value")
|
247 |
+
|
248 |
+
if not config_key:
|
249 |
+
raise HTTPException(status_code=400, detail="缺少配置项键名")
|
250 |
+
|
251 |
+
# 根据配置项类型进行类型转换和验证
|
252 |
+
if config_key == "max_requests_per_minute":
|
253 |
+
try:
|
254 |
+
value = int(config_value)
|
255 |
+
if value <= 0:
|
256 |
+
raise ValueError("每分钟请求限制必须大于0")
|
257 |
+
settings.MAX_REQUESTS_PER_MINUTE = value
|
258 |
+
log('info', f"每分钟请求限制已更新为:{value}")
|
259 |
+
except ValueError as e:
|
260 |
+
raise HTTPException(status_code=422, detail=f"参数类型错误:{str(e)}")
|
261 |
+
|
262 |
+
elif config_key == "max_requests_per_day_per_ip":
|
263 |
+
try:
|
264 |
+
value = int(config_value)
|
265 |
+
if value <= 0:
|
266 |
+
raise ValueError("每IP每日请求限制必须大于0")
|
267 |
+
settings.MAX_REQUESTS_PER_DAY_PER_IP = value
|
268 |
+
log('info', f"每IP每日请求限制已更新为:{value}")
|
269 |
+
except ValueError as e:
|
270 |
+
raise HTTPException(status_code=422, detail=f"参数类型错误:{str(e)}")
|
271 |
+
|
272 |
+
elif config_key == "fake_streaming":
|
273 |
+
if not isinstance(config_value, bool):
|
274 |
+
raise HTTPException(status_code=422, detail="参数类型错误:应为布尔值")
|
275 |
+
settings.FAKE_STREAMING = config_value
|
276 |
+
log('info', f"假流式请求已更新为:{config_value}")
|
277 |
+
|
278 |
+
# 同步更新vertex配置中的假流式设置
|
279 |
+
try:
|
280 |
+
import app.vertex.config as vertex_config
|
281 |
+
vertex_config.FAKE_STREAMING_ENABLED = config_value # 直接更新全局变量
|
282 |
+
vertex_config.update_config('FAKE_STREAMING', config_value) # 同时调用更新函数
|
283 |
+
log('info', f"已同步更新Vertex中的假流式设置为:{config_value}")
|
284 |
+
except Exception as e:
|
285 |
+
log('warning', f"更新Vertex假流式设置时出错: {str(e)}")
|
286 |
+
|
287 |
+
elif config_key == "enable_vertex_express":
|
288 |
+
if not isinstance(config_value, bool):
|
289 |
+
raise HTTPException(status_code=422, detail="参数类型错误:应为布尔值")
|
290 |
+
settings.ENABLE_VERTEX_EXPRESS = config_value
|
291 |
+
log('info', f"Vertex Express已更新为:{config_value}")
|
292 |
+
|
293 |
+
elif config_key == "vertex_express_api_key":
|
294 |
+
if not isinstance(config_value, str):
|
295 |
+
raise HTTPException(status_code=422, detail="参数类型错误:应为字符串")
|
296 |
+
|
297 |
+
# 检查是否为空字符串或"true",如果是,则不更新
|
298 |
+
if not config_value or config_value.lower() == "true":
|
299 |
+
log('info', f"Vertex Express API Key未更新,因为值为空或为'true'")
|
300 |
+
else:
|
301 |
+
settings.VERTEX_EXPRESS_API_KEY = config_value
|
302 |
+
# 更新app_config中的API密钥列表
|
303 |
+
app_config.VERTEX_EXPRESS_API_KEY_VAL = [key.strip() for key in config_value.split(',') if key.strip()]
|
304 |
+
log('info', f"Vertex Express API Key已更新,共{len(app_config.VERTEX_EXPRESS_API_KEY_VAL)}个有效密钥")
|
305 |
+
|
306 |
+
# 尝试刷新模型配置
|
307 |
+
try:
|
308 |
+
from app.vertex.model_loader import refresh_models_config_cache
|
309 |
+
refresh_success = await refresh_models_config_cache()
|
310 |
+
if refresh_success:
|
311 |
+
log('info', "更新Express API Key后成功刷新模型配置")
|
312 |
+
else:
|
313 |
+
log('warning', "更新Express API Key后刷新模型配置失败,将使用默认模型或现有缓存")
|
314 |
+
except Exception as e:
|
315 |
+
log('warning', f"尝试刷新模型配置时出错: {str(e)}")
|
316 |
+
|
317 |
+
elif config_key == "fake_streaming_interval":
|
318 |
+
try:
|
319 |
+
value = float(config_value)
|
320 |
+
if value <= 0:
|
321 |
+
raise ValueError("假流式间隔必须大于0")
|
322 |
+
settings.FAKE_STREAMING_INTERVAL = value
|
323 |
+
log('info', f"假流式间隔已更新为:{value}")
|
324 |
+
|
325 |
+
# 同步更新vertex配置中的假流式间隔设置
|
326 |
+
try:
|
327 |
+
import app.vertex.config as vertex_config
|
328 |
+
vertex_config.update_config('FAKE_STREAMING_INTERVAL', value)
|
329 |
+
log('info', f"已同步更新Vertex中的假流式间隔设置为:{value}")
|
330 |
+
except Exception as e:
|
331 |
+
log('warning', f"更新Vertex假流式间隔设置时出错: {str(e)}")
|
332 |
+
except ValueError as e:
|
333 |
+
raise HTTPException(status_code=422, detail=f"参数类型错误:{str(e)}")
|
334 |
+
|
335 |
+
elif config_key == "random_string":
|
336 |
+
if not isinstance(config_value, bool):
|
337 |
+
raise HTTPException(status_code=422, detail="参数类型错误:应为布尔值")
|
338 |
+
settings.RANDOM_STRING = config_value
|
339 |
+
log('info', f"随机字符串已更新为:{config_value}")
|
340 |
+
elif config_key == "random_string_length":
|
341 |
+
try:
|
342 |
+
value = int(config_value)
|
343 |
+
if value <= 0:
|
344 |
+
raise ValueError("随机字符串长度必须大于0")
|
345 |
+
settings.RANDOM_STRING_LENGTH = value
|
346 |
+
log('info', f"随机字符串长度已更新为:{value}")
|
347 |
+
except ValueError as e:
|
348 |
+
raise HTTPException(status_code=422, detail=f"参数类型错误:{str(e)}")
|
349 |
+
|
350 |
+
elif config_key == "search_mode":
|
351 |
+
if not isinstance(config_value, bool):
|
352 |
+
raise HTTPException(status_code=422, detail="参数类型错误:应为布尔值")
|
353 |
+
settings.search["search_mode"] = config_value
|
354 |
+
log('info', f"联网搜索模式已更新为:{config_value}")
|
355 |
+
|
356 |
+
# 在切换search_mode时,重新获取一次可用模型列表
|
357 |
+
try:
|
358 |
+
# 重置密钥栈以确保随机性
|
359 |
+
key_manager._reset_key_stack()
|
360 |
+
# 获取一个随机API密钥
|
361 |
+
for key in key_manager.api_keys:
|
362 |
+
log('info', f"使用API密钥 {key[:8]}... 刷新可用模型列表")
|
363 |
+
# 使用随机密钥获取可用模型
|
364 |
+
all_models = await GeminiClient.list_available_models(key)
|
365 |
+
GeminiClient.AVAILABLE_MODELS = [model.replace("models/", "") for model in all_models]
|
366 |
+
if len(GeminiClient.AVAILABLE_MODELS) > 0:
|
367 |
+
log('info', f"可用模型列表已更新,当前模型数量:{len(GeminiClient.AVAILABLE_MODELS)}")
|
368 |
+
break
|
369 |
+
else:
|
370 |
+
log('warning', f"没有可用的API密钥,无法刷新可用模型列表")
|
371 |
+
except Exception as e:
|
372 |
+
log('warning', f"刷新可用模型列表时发生错误: {str(e)}")
|
373 |
+
|
374 |
+
elif config_key == "concurrent_requests":
|
375 |
+
try:
|
376 |
+
value = int(config_value)
|
377 |
+
if value <= 0:
|
378 |
+
raise ValueError("并发请求数必须大于0")
|
379 |
+
settings.CONCURRENT_REQUESTS = value
|
380 |
+
log('info', f"并发请求数已更新为:{value}")
|
381 |
+
except ValueError as e:
|
382 |
+
raise HTTPException(status_code=422, detail=f"参数类型错误:{str(e)}")
|
383 |
+
|
384 |
+
elif config_key == "increase_concurrent_on_failure":
|
385 |
+
try:
|
386 |
+
value = int(config_value)
|
387 |
+
if value < 0:
|
388 |
+
raise ValueError("失败时增加的并发数不能为负数")
|
389 |
+
settings.INCREASE_CONCURRENT_ON_FAILURE = value
|
390 |
+
log('info', f"失败时增加的并发数已更新为:{value}")
|
391 |
+
except ValueError as e:
|
392 |
+
raise HTTPException(status_code=422, detail=f"参数类型错误:{str(e)}")
|
393 |
+
|
394 |
+
elif config_key == "max_concurrent_requests":
|
395 |
+
try:
|
396 |
+
value = int(config_value)
|
397 |
+
if value <= 0:
|
398 |
+
raise ValueError("最大并发请求数必须大于0")
|
399 |
+
settings.MAX_CONCURRENT_REQUESTS = value
|
400 |
+
log('info', f"最大并发请求数已更新为:{value}")
|
401 |
+
except ValueError as e:
|
402 |
+
raise HTTPException(status_code=422, detail=f"参数类型错误:{str(e)}")
|
403 |
+
|
404 |
+
elif config_key == "enable_vertex":
|
405 |
+
if not isinstance(config_value, bool):
|
406 |
+
raise HTTPException(status_code=422, detail="参数类型错误:应为布尔值")
|
407 |
+
settings.ENABLE_VERTEX = config_value
|
408 |
+
log('info', f"Vertex AI 已更新为:{config_value}")
|
409 |
+
|
410 |
+
elif config_key == "google_credentials_json":
|
411 |
+
if not isinstance(config_value, str): # Allow empty string to clear
|
412 |
+
raise HTTPException(status_code=422, detail="参数类型错误:Google Credentials JSON 应为字符串")
|
413 |
+
|
414 |
+
# 检查是否为空字符串或"true",如果是,则不更新
|
415 |
+
if not config_value or config_value.lower() == "true":
|
416 |
+
log('info', f"Google Credentials JSON未更新,因为值为空或为'true'")
|
417 |
+
save_settings() # 仍然保存其他可能的设置更改
|
418 |
+
return {"status": "success", "message": f"配置项 {config_key} 未更新,值为空或为'true'"}
|
419 |
+
|
420 |
+
# Validate JSON structure if not empty
|
421 |
+
if config_value:
|
422 |
+
try:
|
423 |
+
# Attempt to parse as single or multiple JSONs
|
424 |
+
# parse_multiple_json_credentials logs errors if parsing fails but returns list.
|
425 |
+
temp_parsed = parse_multiple_json_credentials(config_value)
|
426 |
+
# If parse_multiple_json_credentials returns an empty list for a non-empty string,
|
427 |
+
# it means it didn't find any valid top-level JSON objects as per its logic.
|
428 |
+
# We can do an additional check for a single valid JSON object.
|
429 |
+
if not temp_parsed: # and config_value.strip(): # ensure non-empty string before json.loads
|
430 |
+
try:
|
431 |
+
# This is a stricter check. If parse_multiple_json_credentials, which is more lenient,
|
432 |
+
# failed to find anything, and this also fails, then it's likely malformed.
|
433 |
+
json.loads(config_value) # Try parsing as a single JSON object
|
434 |
+
# If this succeeds, it implies the string IS a valid single JSON,
|
435 |
+
# but not in the multi-JSON format parse_multiple_json_credentials might be looking for initially.
|
436 |
+
# parse_multiple_json_credentials will be called again later and should handle it.
|
437 |
+
except json.JSONDecodeError:
|
438 |
+
# This specific error means it's not even a valid single JSON.
|
439 |
+
raise HTTPException(status_code=422, detail="Google Credentials JSON 格式无效。它既不是有效的单个JSON对象,也不是逗号分隔的多个JSON对象。")
|
440 |
+
except HTTPException: # Re-raise if it's already an HTTPException from inner check
|
441 |
+
raise
|
442 |
+
except Exception as e: # Catch any other error during this pre-check
|
443 |
+
# This might catch errors if parse_multiple_json_credentials itself had an unexpected issue
|
444 |
+
# not related to JSONDecodeError but still an error.
|
445 |
+
raise HTTPException(status_code=422, detail=f"Google Credentials JSON 预检查失败: {str(e)}")
|
446 |
+
|
447 |
+
settings.GOOGLE_CREDENTIALS_JSON = config_value
|
448 |
+
log('info', "Google Credentials JSON 设置已更新 (内容未记录)。")
|
449 |
+
|
450 |
+
# Reset global fallback client first
|
451 |
+
reset_global_fallback_client()
|
452 |
+
|
453 |
+
# Clear previously loaded JSON string credentials from manager
|
454 |
+
if credential_manager is not None:
|
455 |
+
cleared_count = credential_manager.clear_json_string_credentials()
|
456 |
+
log('info', f"从 CredentialManager 中清除了 {cleared_count} 个先前由 JSON 字符串加载的凭据。")
|
457 |
+
|
458 |
+
if config_value: # If new JSON string is provided
|
459 |
+
parsed_json_objects = parse_multiple_json_credentials(config_value)
|
460 |
+
if parsed_json_objects:
|
461 |
+
loaded_count = credential_manager.load_credentials_from_json_list(parsed_json_objects)
|
462 |
+
if loaded_count > 0:
|
463 |
+
log('info', f"从更新的 Google Credentials JSON 中加载了 {loaded_count} 个凭据到 CredentialManager。")
|
464 |
+
else:
|
465 |
+
log('warning', "尝试加载Google Credentials JSON凭据失败,没有凭据被成功加载。")
|
466 |
+
else:
|
467 |
+
# 尝试作为单个JSON对象加载
|
468 |
+
try:
|
469 |
+
single_cred = json.loads(config_value)
|
470 |
+
if credential_manager.add_credential_from_json(single_cred):
|
471 |
+
log('info', "作为单个JSON对象成功加载了一个凭据。")
|
472 |
+
else:
|
473 |
+
log('warning', "作为单个JSON对象加载凭据失败。")
|
474 |
+
except json.JSONDecodeError:
|
475 |
+
log('warning', "Google Credentials JSON无法作为JSON对象解析。")
|
476 |
+
except Exception as e:
|
477 |
+
log('warning', f"尝试加载单个JSON凭据时出错: {str(e)}")
|
478 |
+
else:
|
479 |
+
log('info', "Google Credentials JSON 已被清空。CredentialManager 中来自 JSON 字符串的凭据已被移除。")
|
480 |
+
|
481 |
+
# 检查凭证是否存在
|
482 |
+
if credential_manager.get_total_credentials() == 0:
|
483 |
+
log('warning', "警告:当前没有可用的凭证。Vertex AI功能可能无法正常工作。")
|
484 |
+
else:
|
485 |
+
log('warning', "CredentialManager未初始化,无法加载Google Credentials JSON。")
|
486 |
+
|
487 |
+
# Save all settings changes
|
488 |
+
save_settings() # Moved save_settings here to ensure it's called for this key
|
489 |
+
|
490 |
+
# Trigger re-initialization of Vertex AI (which can re-init the global client)
|
491 |
+
try:
|
492 |
+
# 检查credential_manager是否可用
|
493 |
+
if credential_manager is None:
|
494 |
+
log('warning', "重新初始化Vertex AI时发现credential_manager为None")
|
495 |
+
else:
|
496 |
+
log('info', f"开始重新初始化Vertex AI,当前凭证数: {credential_manager.get_total_credentials()}")
|
497 |
+
|
498 |
+
# 调用run_blocking_init_vertex
|
499 |
+
await run_blocking_init_vertex()
|
500 |
+
log('info', "Vertex AI服务重新初始化完成")
|
501 |
+
|
502 |
+
# 显式刷新模型配置缓存
|
503 |
+
from app.vertex.model_loader import refresh_models_config_cache
|
504 |
+
refresh_success = await refresh_models_config_cache()
|
505 |
+
if refresh_success:
|
506 |
+
log('info', "成功刷新模型配置缓存")
|
507 |
+
else:
|
508 |
+
log('warning', "刷新模型配置缓存失败,将使用默认模型或现有缓存")
|
509 |
+
except Exception as e:
|
510 |
+
log('error', f"重新初始化Vertex AI服务时出错: {str(e)}")
|
511 |
+
|
512 |
+
elif config_key == "max_retry_num":
|
513 |
+
try:
|
514 |
+
value = int(config_value)
|
515 |
+
if value <= 0:
|
516 |
+
raise ValueError("最大重试次数必须大于0")
|
517 |
+
settings.MAX_RETRY_NUM = value
|
518 |
+
log('info', f"最大重试次数已更新为:{value}")
|
519 |
+
except ValueError as e:
|
520 |
+
raise HTTPException(status_code=422, detail=f"参数类型错误:{str(e)}")
|
521 |
+
|
522 |
+
elif config_key == "search_prompt":
|
523 |
+
if not isinstance(config_value, str):
|
524 |
+
raise HTTPException(status_code=422, detail="参数类型错误:应为字符串")
|
525 |
+
settings.search["search_prompt"] = config_value
|
526 |
+
log('info', f"联网搜索提示已更新为:{config_value}")
|
527 |
+
|
528 |
+
elif config_key == "gemini_api_keys":
|
529 |
+
if not isinstance(config_value, str):
|
530 |
+
raise HTTPException(status_code=422, detail="参数类型错误:API密钥应为逗号分隔的字符串")
|
531 |
+
|
532 |
+
# 分割并清理API密钥
|
533 |
+
new_keys = [key.strip() for key in config_value.split(',') if key.strip()]
|
534 |
+
if not new_keys:
|
535 |
+
raise HTTPException(status_code=400, detail="未提供有效的API密钥")
|
536 |
+
|
537 |
+
# 添加到现有的API密钥字符串中
|
538 |
+
current_keys = settings.GEMINI_API_KEYS.split(',') if settings.GEMINI_API_KEYS else []
|
539 |
+
current_keys = [key.strip() for key in current_keys if key.strip()]
|
540 |
+
|
541 |
+
# 合并新旧密钥并去重
|
542 |
+
all_keys = list(set(current_keys + new_keys))
|
543 |
+
settings.GEMINI_API_KEYS = ','.join(all_keys)
|
544 |
+
|
545 |
+
# 计算新添加的密钥数量
|
546 |
+
added_key_count = 0
|
547 |
+
for key in new_keys:
|
548 |
+
if key not in key_manager.api_keys:
|
549 |
+
key_manager.api_keys.append(key)
|
550 |
+
added_key_count += 1
|
551 |
+
|
552 |
+
# 重置密钥栈
|
553 |
+
key_manager._reset_key_stack()
|
554 |
+
|
555 |
+
# 如果可用模型为空,尝试获取模型列表
|
556 |
+
if not GeminiClient.AVAILABLE_MODELS:
|
557 |
+
try:
|
558 |
+
# 使用新添加的密钥之一尝试获取可用模型
|
559 |
+
for key in new_keys:
|
560 |
+
log('info', f"使用新添加的API密钥 {key[:8]}... 获取可用模型列表")
|
561 |
+
all_models = await GeminiClient.list_available_models(key)
|
562 |
+
GeminiClient.AVAILABLE_MODELS = [model.replace("models/", "") for model in all_models]
|
563 |
+
if GeminiClient.AVAILABLE_MODELS:
|
564 |
+
log('info', f"成功获取可用模型列表,共 {len(GeminiClient.AVAILABLE_MODELS)} 个模型")
|
565 |
+
break
|
566 |
+
except Exception as e:
|
567 |
+
log('warning', f"获取可用模型列表时发生错误: {str(e)}")
|
568 |
+
|
569 |
+
log('info', f"已添加 {added_key_count} 个新API密钥,当前共有 {len(key_manager.api_keys)} 个")
|
570 |
+
|
571 |
+
elif config_key == "max_empty_responses":
|
572 |
+
try:
|
573 |
+
value = int(config_value)
|
574 |
+
if value < 0: # 通常至少为0或1,根据实际需求调整
|
575 |
+
raise ValueError("空响应重试次数不能为负数")
|
576 |
+
settings.MAX_EMPTY_RESPONSES = value
|
577 |
+
log('info', f"空响应重试次数已更新为:{value}")
|
578 |
+
except ValueError as e:
|
579 |
+
raise HTTPException(status_code=422, detail=f"参数类型错误:{str(e)}")
|
580 |
+
|
581 |
+
else:
|
582 |
+
raise HTTPException(status_code=400, detail=f"不支持的配置项:{config_key}")
|
583 |
+
save_settings()
|
584 |
+
return {"status": "success", "message": f"配置项 {config_key} 已更新"}
|
585 |
+
except HTTPException:
|
586 |
+
raise
|
587 |
+
except Exception as e:
|
588 |
+
raise HTTPException(status_code=500, detail=f"更新失败:{str(e)}")
|
589 |
+
|
590 |
+
@dashboard_router.post("/test-api-keys")
|
591 |
+
async def test_api_keys(password_data: dict):
|
592 |
+
"""
|
593 |
+
测试所有API密��的有效性
|
594 |
+
|
595 |
+
Args:
|
596 |
+
password_data (dict): 包含密码的字典
|
597 |
+
|
598 |
+
Returns:
|
599 |
+
dict: 操作结果
|
600 |
+
"""
|
601 |
+
try:
|
602 |
+
if not isinstance(password_data, dict):
|
603 |
+
raise HTTPException(status_code=422, detail="请求体格式错误:应为JSON对象")
|
604 |
+
|
605 |
+
password = password_data.get("password")
|
606 |
+
if not password:
|
607 |
+
raise HTTPException(status_code=400, detail="缺少密码参数")
|
608 |
+
|
609 |
+
if not isinstance(password, str):
|
610 |
+
raise HTTPException(status_code=422, detail="密码参数类型错误:应为字符串")
|
611 |
+
|
612 |
+
if not verify_web_password(password):
|
613 |
+
raise HTTPException(status_code=401, detail="密码错误")
|
614 |
+
|
615 |
+
# 检查是否已经有测试在运行
|
616 |
+
if api_key_test_progress["is_running"]:
|
617 |
+
raise HTTPException(status_code=409, detail="已有API密钥检测正在进行中")
|
618 |
+
|
619 |
+
# 获取有效密钥列表
|
620 |
+
valid_keys = key_manager.api_keys.copy()
|
621 |
+
|
622 |
+
# 启动异步测试
|
623 |
+
threading.Thread(
|
624 |
+
target=start_api_key_test_in_thread,
|
625 |
+
args=(valid_keys,),
|
626 |
+
daemon=True
|
627 |
+
).start()
|
628 |
+
|
629 |
+
return {"status": "success", "message": "API密钥检测已启动,将同时检测有效密钥和无效密钥"}
|
630 |
+
except HTTPException:
|
631 |
+
raise
|
632 |
+
except Exception as e:
|
633 |
+
raise HTTPException(status_code=500, detail=f"启动API密钥检测失败:{str(e)}")
|
634 |
+
|
635 |
+
@dashboard_router.get("/test-api-keys/progress")
|
636 |
+
async def get_test_api_keys_progress():
|
637 |
+
"""
|
638 |
+
获取API密钥检测进度
|
639 |
+
|
640 |
+
Returns:
|
641 |
+
dict: 进度信息
|
642 |
+
"""
|
643 |
+
return api_key_test_progress
|
644 |
+
|
645 |
+
def check_api_key_in_thread(key):
|
646 |
+
"""在线程中检查单个API密钥的有效性"""
|
647 |
+
loop = asyncio.new_event_loop()
|
648 |
+
asyncio.set_event_loop(loop)
|
649 |
+
try:
|
650 |
+
is_valid = loop.run_until_complete(test_api_key(key))
|
651 |
+
if is_valid:
|
652 |
+
log('info', f"API密钥 {key[:8]}... 有效")
|
653 |
+
return key, True
|
654 |
+
else:
|
655 |
+
log('warning', f"API密钥 {key[:8]}... 无效")
|
656 |
+
return key, False
|
657 |
+
finally:
|
658 |
+
loop.close()
|
659 |
+
|
660 |
+
async def test_api_key(key):
|
661 |
+
"""测试单个API密钥是否有效"""
|
662 |
+
try:
|
663 |
+
# 尝试列出可用模型来检查API密钥是否有效
|
664 |
+
all_models = await GeminiClient.list_available_models(key)
|
665 |
+
return len(all_models) > 0
|
666 |
+
except Exception as e:
|
667 |
+
log('error', f"测试API密钥 {key[:8]}... 时出错: {str(e)}")
|
668 |
+
return False
|
669 |
+
|
670 |
+
def start_api_key_test_in_thread(keys):
|
671 |
+
"""在线程中启动API密钥检测过程"""
|
672 |
+
# 重置进度信息
|
673 |
+
api_key_test_progress.update({
|
674 |
+
"is_running": True,
|
675 |
+
"completed": 0,
|
676 |
+
"total": 0, # 稍后会更新
|
677 |
+
"valid": 0,
|
678 |
+
"invalid": 0,
|
679 |
+
"is_completed": False
|
680 |
+
})
|
681 |
+
|
682 |
+
try:
|
683 |
+
# 获取所有需要检测的密钥(包括当前GEMINI_API_KEYS和INVALID_API_KEYS)
|
684 |
+
current_keys = keys
|
685 |
+
|
686 |
+
# 获取当前无效密钥
|
687 |
+
invalid_api_keys = settings.INVALID_API_KEYS.split(',') if settings.INVALID_API_KEYS else []
|
688 |
+
invalid_api_keys = [key.strip() for key in invalid_api_keys if key.strip()]
|
689 |
+
|
690 |
+
# 合并所有需要测试的密钥,去重
|
691 |
+
all_keys_to_test = list(set(current_keys + invalid_api_keys))
|
692 |
+
|
693 |
+
# 更新总数
|
694 |
+
api_key_test_progress["total"] = len(all_keys_to_test)
|
695 |
+
|
696 |
+
# 创建有效和无效密钥列表
|
697 |
+
valid_keys = []
|
698 |
+
invalid_keys = []
|
699 |
+
|
700 |
+
# 检查每个密钥
|
701 |
+
for key in all_keys_to_test:
|
702 |
+
# 检查密钥
|
703 |
+
_, is_valid = check_api_key_in_thread(key)
|
704 |
+
|
705 |
+
# 更新进度
|
706 |
+
api_key_test_progress["completed"] += 1
|
707 |
+
|
708 |
+
# 将密钥添加到相应列表
|
709 |
+
if is_valid:
|
710 |
+
valid_keys.append(key)
|
711 |
+
api_key_test_progress["valid"] += 1
|
712 |
+
else:
|
713 |
+
invalid_keys.append(key)
|
714 |
+
api_key_test_progress["invalid"] += 1
|
715 |
+
|
716 |
+
# 更新全局密钥列表
|
717 |
+
key_manager.api_keys = valid_keys
|
718 |
+
|
719 |
+
# 更新设置中的有效和无效密钥
|
720 |
+
settings.GEMINI_API_KEYS = ','.join(valid_keys)
|
721 |
+
settings.INVALID_API_KEYS = ','.join(invalid_keys)
|
722 |
+
|
723 |
+
# 保存设置
|
724 |
+
save_settings()
|
725 |
+
|
726 |
+
# 重置密钥栈
|
727 |
+
key_manager._reset_key_stack()
|
728 |
+
|
729 |
+
log('info', f"API密钥检测完成。有效密钥: {len(valid_keys)},无效密钥: {len(invalid_keys)}")
|
730 |
+
except Exception as e:
|
731 |
+
log('error', f"API密钥检测过程中发生错误: {str(e)}")
|
732 |
+
finally:
|
733 |
+
# 标记检测完成
|
734 |
+
api_key_test_progress.update({
|
735 |
+
"is_running": False,
|
736 |
+
"is_completed": True
|
737 |
+
})
|
738 |
+
|
739 |
+
@dashboard_router.post("/clear-invalid-api-keys")
|
740 |
+
async def clear_invalid_api_keys(password_data: dict):
|
741 |
+
"""
|
742 |
+
清除所有失效的API密钥
|
743 |
+
|
744 |
+
Args:
|
745 |
+
password_data (dict): 包含密码的字典
|
746 |
+
|
747 |
+
Returns:
|
748 |
+
dict: 操作结果
|
749 |
+
"""
|
750 |
+
try:
|
751 |
+
if not isinstance(password_data, dict):
|
752 |
+
raise HTTPException(status_code=422, detail="请求体格式错误:应为JSON对象")
|
753 |
+
|
754 |
+
password = password_data.get("password")
|
755 |
+
if not password:
|
756 |
+
raise HTTPException(status_code=400, detail="缺少密码参数")
|
757 |
+
|
758 |
+
if not isinstance(password, str):
|
759 |
+
raise HTTPException(status_code=422, detail="密码参数类型错误:应为字符串")
|
760 |
+
|
761 |
+
if not verify_web_password(password):
|
762 |
+
raise HTTPException(status_code=401, detail="密码错误")
|
763 |
+
|
764 |
+
# 获取当前无效密钥数量
|
765 |
+
current_invalid_keys = settings.INVALID_API_KEYS.split(',') if settings.INVALID_API_KEYS else []
|
766 |
+
current_invalid_keys = [key.strip() for key in current_invalid_keys if key.strip()]
|
767 |
+
invalid_count = len(current_invalid_keys)
|
768 |
+
|
769 |
+
if invalid_count == 0:
|
770 |
+
return {"status": "success", "message": "没有失效的API密钥需要清除"}
|
771 |
+
|
772 |
+
# 清除无效密钥
|
773 |
+
settings.INVALID_API_KEYS = ""
|
774 |
+
save_settings()
|
775 |
+
|
776 |
+
log('info', f"已清除 {invalid_count} 个失效的API密钥")
|
777 |
+
|
778 |
+
return {
|
779 |
+
"status": "success",
|
780 |
+
"message": f"已成功清除 {invalid_count} 个失效的API密钥"
|
781 |
+
}
|
782 |
+
except HTTPException:
|
783 |
+
raise
|
784 |
+
except Exception as e:
|
785 |
+
raise HTTPException(status_code=500, detail=f"清除失效API密钥失败:{str(e)}")
|
786 |
+
|
787 |
+
@dashboard_router.post("/export-valid-api-keys")
|
788 |
+
async def export_valid_api_keys(password_data: dict):
|
789 |
+
"""
|
790 |
+
输出所有有效的API密钥
|
791 |
+
|
792 |
+
Args:
|
793 |
+
password_data (dict): 包含密码的字典
|
794 |
+
|
795 |
+
Returns:
|
796 |
+
dict: 操作结果,包含有效密钥列表
|
797 |
+
"""
|
798 |
+
try:
|
799 |
+
if not isinstance(password_data, dict):
|
800 |
+
raise HTTPException(status_code=422, detail="请求体格式错误:应为JSON对象")
|
801 |
+
|
802 |
+
password = password_data.get("password")
|
803 |
+
if not password:
|
804 |
+
raise HTTPException(status_code=400, detail="缺少密码参数")
|
805 |
+
|
806 |
+
if not isinstance(password, str):
|
807 |
+
raise HTTPException(status_code=422, detail="密码参数类型错误:应为字符串")
|
808 |
+
|
809 |
+
if not verify_web_password(password):
|
810 |
+
raise HTTPException(status_code=401, detail="密码错误")
|
811 |
+
|
812 |
+
# 获取当前有效密钥
|
813 |
+
valid_keys = key_manager.api_keys.copy()
|
814 |
+
|
815 |
+
if not valid_keys:
|
816 |
+
return {"status": "success", "message": "当前没有有效的API密钥", "keys": []}
|
817 |
+
|
818 |
+
# 直接返回完整的密钥列表
|
819 |
+
log('info', f"用户导出了 {len(valid_keys)} 个有效API密钥")
|
820 |
+
|
821 |
+
return {
|
822 |
+
"status": "success",
|
823 |
+
"message": f"成功获取 {len(valid_keys)} 个有效API密钥",
|
824 |
+
"keys": valid_keys,
|
825 |
+
"count": len(valid_keys)
|
826 |
+
}
|
827 |
+
except HTTPException:
|
828 |
+
raise
|
829 |
+
except Exception as e:
|
830 |
+
raise HTTPException(status_code=500, detail=f"获取有效API密钥失败:{str(e)}")
|
app/api/nonstream_handlers.py
ADDED
@@ -0,0 +1,577 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import asyncio
|
2 |
+
from fastapi import HTTPException, Request
|
3 |
+
from fastapi.responses import StreamingResponse, Response
|
4 |
+
from app.models.schemas import ChatCompletionRequest
|
5 |
+
from app.services import GeminiClient
|
6 |
+
from app.utils import update_api_call_stats
|
7 |
+
from app.utils.error_handling import handle_gemini_error
|
8 |
+
from app.utils.logging import log
|
9 |
+
import app.config.settings as settings
|
10 |
+
from typing import Literal
|
11 |
+
from app.utils.response import gemini_from_text, openAI_from_Gemini, openAI_from_text
|
12 |
+
from app.utils.stats import get_api_key_usage
|
13 |
+
|
14 |
+
|
15 |
+
# 非流式请求处理函数
|
16 |
+
async def process_nonstream_request(
|
17 |
+
chat_request: ChatCompletionRequest,
|
18 |
+
contents,
|
19 |
+
system_instruction,
|
20 |
+
current_api_key: str,
|
21 |
+
response_cache_manager,
|
22 |
+
safety_settings,
|
23 |
+
safety_settings_g2,
|
24 |
+
cache_key: str
|
25 |
+
):
|
26 |
+
"""处理非流式API请求"""
|
27 |
+
gemini_client = GeminiClient(current_api_key)
|
28 |
+
# 创建调用 Gemini API 的主任务
|
29 |
+
gemini_task = asyncio.create_task(
|
30 |
+
gemini_client.complete_chat(
|
31 |
+
chat_request,
|
32 |
+
contents,
|
33 |
+
safety_settings_g2 if 'gemini-2.5' in chat_request.model else safety_settings,
|
34 |
+
system_instruction
|
35 |
+
)
|
36 |
+
)
|
37 |
+
# 使用 shield 保护任务不被外部轻易取消
|
38 |
+
shielded_gemini_task = asyncio.shield(gemini_task)
|
39 |
+
|
40 |
+
try:
|
41 |
+
# 等待受保护的 API 调用任务完成
|
42 |
+
response_content = await shielded_gemini_task
|
43 |
+
response_content.set_model(chat_request.model)
|
44 |
+
|
45 |
+
# 检查响应内容是否为空
|
46 |
+
if not response_content or (not response_content.text and not response_content.function_call):
|
47 |
+
log('warning', f"API密钥 {current_api_key[:8]}... 返回空响应",
|
48 |
+
extra={'key': current_api_key[:8], 'request_type': 'non-stream', 'model': chat_request.model})
|
49 |
+
return "empty"
|
50 |
+
|
51 |
+
# 缓存响应结果
|
52 |
+
await response_cache_manager.store(cache_key, response_content)
|
53 |
+
# 更新 API 调用统计
|
54 |
+
await update_api_call_stats(settings.api_call_stats, endpoint=current_api_key, model=chat_request.model,token=response_content.total_token_count)
|
55 |
+
|
56 |
+
return "success"
|
57 |
+
|
58 |
+
except Exception as e:
|
59 |
+
# 处理 API 调用过程中可能发生的任何异常
|
60 |
+
handle_gemini_error(e, current_api_key)
|
61 |
+
return "error"
|
62 |
+
|
63 |
+
|
64 |
+
# 带保活功能的非流式请求处理函数
|
65 |
+
async def process_nonstream_request_with_keepalive(
|
66 |
+
chat_request: ChatCompletionRequest,
|
67 |
+
contents,
|
68 |
+
system_instruction,
|
69 |
+
current_api_key: str,
|
70 |
+
response_cache_manager,
|
71 |
+
safety_settings,
|
72 |
+
safety_settings_g2,
|
73 |
+
cache_key: str,
|
74 |
+
keepalive_interval: float = 30.0 # 保活间隔,默认30秒
|
75 |
+
):
|
76 |
+
"""处理非流式API请求,带TCP保活功能"""
|
77 |
+
gemini_client = GeminiClient(current_api_key)
|
78 |
+
|
79 |
+
# 创建调用 Gemini API 的主任务
|
80 |
+
gemini_task = asyncio.create_task(
|
81 |
+
gemini_client.complete_chat(
|
82 |
+
chat_request,
|
83 |
+
contents,
|
84 |
+
safety_settings_g2 if 'gemini-2.5' in chat_request.model else safety_settings,
|
85 |
+
system_instruction
|
86 |
+
)
|
87 |
+
)
|
88 |
+
|
89 |
+
# 创建保活任务
|
90 |
+
keepalive_task = asyncio.create_task(
|
91 |
+
send_keepalive_messages(keepalive_interval)
|
92 |
+
)
|
93 |
+
|
94 |
+
try:
|
95 |
+
# 等待Gemini任务完成
|
96 |
+
response_content = await gemini_task
|
97 |
+
|
98 |
+
# 取消保活任务
|
99 |
+
keepalive_task.cancel()
|
100 |
+
|
101 |
+
response_content.set_model(chat_request.model)
|
102 |
+
|
103 |
+
# 检查响应内容是否为空
|
104 |
+
if not response_content or (not response_content.text and not response_content.function_call):
|
105 |
+
log('warning', f"API密钥 {current_api_key[:8]}... 返回空响应",
|
106 |
+
extra={'key': current_api_key[:8], 'request_type': 'non-stream', 'model': chat_request.model})
|
107 |
+
return "empty"
|
108 |
+
|
109 |
+
# 缓存响应结果
|
110 |
+
await response_cache_manager.store(cache_key, response_content)
|
111 |
+
# 更新 API 调用统计
|
112 |
+
await update_api_call_stats(settings.api_call_stats, endpoint=current_api_key, model=chat_request.model,token=response_content.total_token_count)
|
113 |
+
|
114 |
+
return "success"
|
115 |
+
|
116 |
+
except Exception as e:
|
117 |
+
# 取消保活任务
|
118 |
+
keepalive_task.cancel()
|
119 |
+
# 处理 API 调用过程中可能发生的任何异常
|
120 |
+
handle_gemini_error(e, current_api_key)
|
121 |
+
return "error"
|
122 |
+
|
123 |
+
|
124 |
+
# 简化的保活功能 - 在等待期间发送换行符
|
125 |
+
async def process_nonstream_request_with_simple_keepalive(
|
126 |
+
chat_request: ChatCompletionRequest,
|
127 |
+
contents,
|
128 |
+
system_instruction,
|
129 |
+
current_api_key: str,
|
130 |
+
response_cache_manager,
|
131 |
+
safety_settings,
|
132 |
+
safety_settings_g2,
|
133 |
+
cache_key: str,
|
134 |
+
keepalive_interval: float = 30.0 # 保活间隔,默认30秒
|
135 |
+
):
|
136 |
+
"""处理非流式API请求,带简化TCP保活功能"""
|
137 |
+
gemini_client = GeminiClient(current_api_key)
|
138 |
+
|
139 |
+
# 创建调用 Gemini API 的��任务
|
140 |
+
gemini_task = asyncio.create_task(
|
141 |
+
gemini_client.complete_chat(
|
142 |
+
chat_request,
|
143 |
+
contents,
|
144 |
+
safety_settings_g2 if 'gemini-2.5' in chat_request.model else safety_settings,
|
145 |
+
system_instruction
|
146 |
+
)
|
147 |
+
)
|
148 |
+
|
149 |
+
# 创建保活任务
|
150 |
+
keepalive_task = asyncio.create_task(
|
151 |
+
send_keepalive_messages(keepalive_interval)
|
152 |
+
)
|
153 |
+
|
154 |
+
try:
|
155 |
+
# 等待Gemini任务完成
|
156 |
+
response_content = await gemini_task
|
157 |
+
|
158 |
+
# 取消保活任务
|
159 |
+
keepalive_task.cancel()
|
160 |
+
|
161 |
+
response_content.set_model(chat_request.model)
|
162 |
+
|
163 |
+
# 检查响应内容是否为空
|
164 |
+
if not response_content or (not response_content.text and not response_content.function_call):
|
165 |
+
log('warning', f"API密钥 {current_api_key[:8]}... 返回空响应",
|
166 |
+
extra={'key': current_api_key[:8], 'request_type': 'non-stream', 'model': chat_request.model})
|
167 |
+
return "empty"
|
168 |
+
|
169 |
+
# 缓存响应结果
|
170 |
+
await response_cache_manager.store(cache_key, response_content)
|
171 |
+
# 更新 API 调用统计
|
172 |
+
await update_api_call_stats(settings.api_call_stats, endpoint=current_api_key, model=chat_request.model,token=response_content.total_token_count)
|
173 |
+
|
174 |
+
return "success"
|
175 |
+
|
176 |
+
except Exception as e:
|
177 |
+
# 取消保活任务
|
178 |
+
keepalive_task.cancel()
|
179 |
+
# 处理 API 调用过程中可能发生的任何异常
|
180 |
+
handle_gemini_error(e, current_api_key)
|
181 |
+
return "error"
|
182 |
+
|
183 |
+
|
184 |
+
async def send_keepalive_messages(interval: float):
|
185 |
+
"""发送保活消息的任务"""
|
186 |
+
try:
|
187 |
+
while True:
|
188 |
+
await asyncio.sleep(interval)
|
189 |
+
# 发送换行符作为保活消息
|
190 |
+
# 注意:在非流式响应中,我们无法直接发送数据到客户端
|
191 |
+
except asyncio.CancelledError:
|
192 |
+
# 任务被取消时正常退出
|
193 |
+
pass
|
194 |
+
except Exception as e:
|
195 |
+
log('error', f"保活任务出错: {str(e)}",
|
196 |
+
extra={'request_type': 'non-stream', 'keepalive': True})
|
197 |
+
|
198 |
+
|
199 |
+
# 处理 route 中发起请求的函数
|
200 |
+
async def process_request(
|
201 |
+
chat_request,
|
202 |
+
key_manager,
|
203 |
+
response_cache_manager,
|
204 |
+
safety_settings,
|
205 |
+
safety_settings_g2,
|
206 |
+
cache_key: str
|
207 |
+
):
|
208 |
+
"""处理非流式请求"""
|
209 |
+
global current_api_key
|
210 |
+
|
211 |
+
format_type = getattr(chat_request, 'format_type', None)
|
212 |
+
if format_type and (format_type == "gemini"):
|
213 |
+
is_gemini = True
|
214 |
+
contents, system_instruction = None,None
|
215 |
+
else:
|
216 |
+
is_gemini = False
|
217 |
+
# 转换消息格式
|
218 |
+
contents, system_instruction = GeminiClient.convert_messages(GeminiClient, chat_request.messages,model=chat_request.model)
|
219 |
+
|
220 |
+
# 设置初始并发数
|
221 |
+
current_concurrent = settings.CONCURRENT_REQUESTS
|
222 |
+
max_retry_num = settings.MAX_RETRY_NUM
|
223 |
+
|
224 |
+
# 当前请求次数
|
225 |
+
current_try_num = 0
|
226 |
+
|
227 |
+
# 空响应计数
|
228 |
+
empty_response_count = 0
|
229 |
+
|
230 |
+
# 尝试使用不同API密钥,直到达到最大重试次数或空响应限制
|
231 |
+
while (current_try_num < max_retry_num) and (empty_response_count < settings.MAX_EMPTY_RESPONSES):
|
232 |
+
# 获取当前批次的密钥数量
|
233 |
+
batch_num = min(max_retry_num - current_try_num, current_concurrent)
|
234 |
+
|
235 |
+
# 获取当前批次的密钥
|
236 |
+
valid_keys = []
|
237 |
+
checked_keys = set() # 用于记录已检查过的密钥
|
238 |
+
all_keys_checked = False # 标记是否已检查所有密钥
|
239 |
+
|
240 |
+
# 尝试获取足够数量的有效密钥
|
241 |
+
while len(valid_keys) < batch_num:
|
242 |
+
api_key = await key_manager.get_available_key()
|
243 |
+
if not api_key:
|
244 |
+
break
|
245 |
+
|
246 |
+
# 如果这个密钥已经检查过,说明已经检查了所有密钥
|
247 |
+
if api_key in checked_keys:
|
248 |
+
all_keys_checked = True
|
249 |
+
break
|
250 |
+
|
251 |
+
checked_keys.add(api_key)
|
252 |
+
# 获取API密钥的调用次数
|
253 |
+
usage = await get_api_key_usage(settings.api_call_stats, api_key)
|
254 |
+
# 如果调用次数小于限制,则添加到有效密钥列表
|
255 |
+
if usage < settings.API_KEY_DAILY_LIMIT:
|
256 |
+
valid_keys.append(api_key)
|
257 |
+
else:
|
258 |
+
log('warning', f"API密钥 {api_key[:8]}... 已达到每日调用限制 ({usage}/{settings.API_KEY_DAILY_LIMIT})",
|
259 |
+
extra={'key': api_key[:8], 'request_type': 'non-stream', 'model': chat_request.model})
|
260 |
+
|
261 |
+
# 如果已经检查了所有密钥且没有找到有效密钥,则重置密钥栈
|
262 |
+
if all_keys_checked and not valid_keys:
|
263 |
+
log('warning', "所有API密钥已达到每日调用限制,重置密钥栈",
|
264 |
+
extra={'request_type': 'non-stream', 'model': chat_request.model})
|
265 |
+
key_manager._reset_key_stack()
|
266 |
+
# 重置后重新获取一个密钥
|
267 |
+
api_key = await key_manager.get_available_key()
|
268 |
+
if api_key:
|
269 |
+
valid_keys = [api_key]
|
270 |
+
|
271 |
+
# 如果没有获取到任何有效密钥,跳出循环
|
272 |
+
if not valid_keys:
|
273 |
+
break
|
274 |
+
|
275 |
+
# 更新当前尝试次数
|
276 |
+
current_try_num += len(valid_keys)
|
277 |
+
|
278 |
+
# 创建并发任务
|
279 |
+
tasks = []
|
280 |
+
tasks_map = {}
|
281 |
+
for api_key in valid_keys:
|
282 |
+
# 记录当前尝试的密钥信息
|
283 |
+
log('info', f"非流式请求开始,使用密钥: {api_key[:8]}...",
|
284 |
+
extra={'key': api_key[:8], 'request_type': 'non-stream', 'model': chat_request.model})
|
285 |
+
|
286 |
+
# 创建任务 - 根据配置决定是否使用保活功能
|
287 |
+
if settings.NONSTREAM_KEEPALIVE_ENABLED:
|
288 |
+
task = asyncio.create_task(
|
289 |
+
process_nonstream_request_with_simple_keepalive(
|
290 |
+
chat_request,
|
291 |
+
contents,
|
292 |
+
system_instruction,
|
293 |
+
api_key,
|
294 |
+
response_cache_manager,
|
295 |
+
safety_settings,
|
296 |
+
safety_settings_g2,
|
297 |
+
cache_key,
|
298 |
+
settings.NONSTREAM_KEEPALIVE_INTERVAL
|
299 |
+
)
|
300 |
+
)
|
301 |
+
else:
|
302 |
+
task = asyncio.create_task(
|
303 |
+
process_nonstream_request(
|
304 |
+
chat_request,
|
305 |
+
contents,
|
306 |
+
system_instruction,
|
307 |
+
api_key,
|
308 |
+
response_cache_manager,
|
309 |
+
safety_settings,
|
310 |
+
safety_settings_g2,
|
311 |
+
cache_key
|
312 |
+
)
|
313 |
+
)
|
314 |
+
tasks.append((api_key, task))
|
315 |
+
tasks_map[task] = api_key
|
316 |
+
|
317 |
+
# 等待所有任务完成或找到成功响应
|
318 |
+
success = False
|
319 |
+
while tasks and not success:
|
320 |
+
# 短时间等待任务完成
|
321 |
+
done, pending = await asyncio.wait(
|
322 |
+
[task for _, task in tasks],
|
323 |
+
return_when=asyncio.FIRST_COMPLETED
|
324 |
+
)
|
325 |
+
# 检查已完成的任务是否成功
|
326 |
+
for task in done:
|
327 |
+
api_key = tasks_map[task]
|
328 |
+
try:
|
329 |
+
status = task.result()
|
330 |
+
# 如果有成功响应内容
|
331 |
+
if status == "success" :
|
332 |
+
success = True
|
333 |
+
log('info', f"非流式请求成功",
|
334 |
+
extra={'key': api_key[:8],'request_type': 'non-stream', 'model': chat_request.model})
|
335 |
+
cached_response, cache_hit = await response_cache_manager.get_and_remove(cache_key)
|
336 |
+
if is_gemini :
|
337 |
+
return cached_response.data
|
338 |
+
else:
|
339 |
+
return openAI_from_Gemini(cached_response,stream=False)
|
340 |
+
elif status == "empty":
|
341 |
+
# 增加空响应计数
|
342 |
+
empty_response_count += 1
|
343 |
+
log('warning', f"空响应计数: {empty_response_count}/{settings.MAX_EMPTY_RESPONSES}",
|
344 |
+
extra={'key': api_key[:8], 'request_type': 'non-stream', 'model': chat_request.model})
|
345 |
+
|
346 |
+
except Exception as e:
|
347 |
+
handle_gemini_error(e, api_key)
|
348 |
+
|
349 |
+
# 更新任务列表,移除已完成的任务
|
350 |
+
tasks = [(k, t) for k, t in tasks if not t.done()]
|
351 |
+
|
352 |
+
# 如果当前批次没有成功响应,并且还有密钥可用,则继续尝试
|
353 |
+
if not success and valid_keys:
|
354 |
+
# 增加并发数,但不超过最大并发数
|
355 |
+
current_concurrent = min(current_concurrent + settings.INCREASE_CONCURRENT_ON_FAILURE, settings.MAX_CONCURRENT_REQUESTS)
|
356 |
+
log('info', f"所有并发请求失败或返回空响应,增加并发数至: {current_concurrent}",
|
357 |
+
extra={'request_type': 'non-stream', 'model': chat_request.model})
|
358 |
+
|
359 |
+
# 如果空响应次数达到限制,跳出循环,并返回酒馆正常响应(包含错误信息)
|
360 |
+
if empty_response_count >= settings.MAX_EMPTY_RESPONSES:
|
361 |
+
log('warning', f"空响应次数达到限制 ({empty_response_count}/{settings.MAX_EMPTY_RESPONSES}),停止轮询",
|
362 |
+
extra={'request_type': 'non-stream', 'model': chat_request.model})
|
363 |
+
|
364 |
+
if is_gemini :
|
365 |
+
return gemini_from_text(content="空响应次数达到上限\n请修改输入提示词",finish_reason="STOP",stream=False)
|
366 |
+
else:
|
367 |
+
return openAI_from_text(model=chat_request.model,content="空响应次数达到上限\n请修改输入提示词",finish_reason="stop",stream=False)
|
368 |
+
|
369 |
+
# 如果所有尝试都失败
|
370 |
+
log('error', "API key 替换失败,所有API key���已尝试,请重新配置或稍后重试", extra={'request_type': 'switch_key'})
|
371 |
+
|
372 |
+
if is_gemini:
|
373 |
+
return gemini_from_text(content="所有API密钥均请求失败\n具体错误请查看轮询日志",finish_reason="STOP",stream=False)
|
374 |
+
else:
|
375 |
+
return openAI_from_text(model=chat_request.model,content="所有API密钥均请求失败\n具体错误请查看轮询日志",finish_reason="stop",stream=False)
|
376 |
+
|
377 |
+
# raise HTTPException(status_code=500, detail=f"API key 替换失败,所有API key都已尝试,请重新配置或稍后重试")
|
378 |
+
|
379 |
+
|
380 |
+
# 处理带保活的非流式请求(使用流式响应)
|
381 |
+
async def process_nonstream_with_keepalive_stream(
|
382 |
+
chat_request,
|
383 |
+
key_manager,
|
384 |
+
response_cache_manager,
|
385 |
+
safety_settings,
|
386 |
+
safety_settings_g2,
|
387 |
+
cache_key: str,
|
388 |
+
is_gemini: bool
|
389 |
+
):
|
390 |
+
"""处理带保活的非流式请求,使用流式响应发送保活消息但最终返回非流式格式"""
|
391 |
+
from fastapi.responses import StreamingResponse
|
392 |
+
import json
|
393 |
+
|
394 |
+
async def keepalive_stream_generator():
|
395 |
+
"""生成带保活的流式响应"""
|
396 |
+
try:
|
397 |
+
# 转换消息格式
|
398 |
+
format_type = getattr(chat_request, 'format_type', None)
|
399 |
+
if format_type and (format_type == "gemini"):
|
400 |
+
contents, system_instruction = None, None
|
401 |
+
else:
|
402 |
+
contents, system_instruction = GeminiClient.convert_messages(GeminiClient, chat_request.messages, model=chat_request.model)
|
403 |
+
|
404 |
+
# 设置初始并发数
|
405 |
+
current_concurrent = settings.CONCURRENT_REQUESTS
|
406 |
+
max_retry_num = settings.MAX_RETRY_NUM
|
407 |
+
|
408 |
+
# 当前请求次数
|
409 |
+
current_try_num = 0
|
410 |
+
|
411 |
+
# 空响应计数
|
412 |
+
empty_response_count = 0
|
413 |
+
|
414 |
+
# 尝试使用不同API密钥,直到达到最大重试次数或空响应限制
|
415 |
+
while (current_try_num < max_retry_num) and (empty_response_count < settings.MAX_EMPTY_RESPONSES):
|
416 |
+
# 获取当前批次的密钥数量
|
417 |
+
batch_num = min(max_retry_num - current_try_num, current_concurrent)
|
418 |
+
|
419 |
+
# 获取当前批次的密钥
|
420 |
+
valid_keys = []
|
421 |
+
checked_keys = set() # 用于记录已检查过的密钥
|
422 |
+
all_keys_checked = False # 标记是否已检查所有密钥
|
423 |
+
|
424 |
+
# 尝试获取足够数量的有效密钥
|
425 |
+
while len(valid_keys) < batch_num:
|
426 |
+
api_key = await key_manager.get_available_key()
|
427 |
+
if not api_key:
|
428 |
+
break
|
429 |
+
|
430 |
+
# 如果这个密钥已经检查过,说明已经检查了所有密钥
|
431 |
+
if api_key in checked_keys:
|
432 |
+
all_keys_checked = True
|
433 |
+
break
|
434 |
+
|
435 |
+
checked_keys.add(api_key)
|
436 |
+
# 获取API密钥的调用次数
|
437 |
+
usage = await get_api_key_usage(settings.api_call_stats, api_key)
|
438 |
+
# 如果调用次数小于限制,则添加到有效密钥列表
|
439 |
+
if usage < settings.API_KEY_DAILY_LIMIT:
|
440 |
+
valid_keys.append(api_key)
|
441 |
+
else:
|
442 |
+
log('warning', f"API密钥 {api_key[:8]}... 已达到每日调用限制 ({usage}/{settings.API_KEY_DAILY_LIMIT})",
|
443 |
+
extra={'key': api_key[:8], 'request_type': 'non-stream', 'model': chat_request.model})
|
444 |
+
|
445 |
+
# 如果已经检查了所有密钥且没有找到有效密钥,则重置密钥栈
|
446 |
+
if all_keys_checked and not valid_keys:
|
447 |
+
log('warning', "所有API密钥已达到每日调用限制,重置密钥栈",
|
448 |
+
extra={'request_type': 'non-stream', 'model': chat_request.model})
|
449 |
+
key_manager._reset_key_stack()
|
450 |
+
# 重置后重新获取一个密钥
|
451 |
+
api_key = await key_manager.get_available_key()
|
452 |
+
if api_key:
|
453 |
+
valid_keys = [api_key]
|
454 |
+
|
455 |
+
# 如果没有获取到任何有效密钥,跳出循环
|
456 |
+
if not valid_keys:
|
457 |
+
break
|
458 |
+
|
459 |
+
# 更新当前尝试次数
|
460 |
+
current_try_num += len(valid_keys)
|
461 |
+
|
462 |
+
# 创建并发任务
|
463 |
+
tasks = []
|
464 |
+
tasks_map = {}
|
465 |
+
for api_key in valid_keys:
|
466 |
+
# 记录当前尝试的密钥信息
|
467 |
+
log('info', f"非流式请求开始,使用密钥: {api_key[:8]}...",
|
468 |
+
extra={'key': api_key[:8], 'request_type': 'non-stream', 'model': chat_request.model})
|
469 |
+
|
470 |
+
# 创建任务
|
471 |
+
task = asyncio.create_task(
|
472 |
+
process_nonstream_request(
|
473 |
+
chat_request,
|
474 |
+
contents,
|
475 |
+
system_instruction,
|
476 |
+
api_key,
|
477 |
+
response_cache_manager,
|
478 |
+
safety_settings,
|
479 |
+
safety_settings_g2,
|
480 |
+
cache_key
|
481 |
+
)
|
482 |
+
)
|
483 |
+
tasks.append((api_key, task))
|
484 |
+
tasks_map[task] = api_key
|
485 |
+
|
486 |
+
# 等待所有任务完成或找到成功响应
|
487 |
+
success = False
|
488 |
+
keepalive_counter = 0
|
489 |
+
while tasks and not success:
|
490 |
+
# 短时间等待任务完成
|
491 |
+
done, pending = await asyncio.wait(
|
492 |
+
[task for _, task in tasks],
|
493 |
+
timeout=settings.NONSTREAM_KEEPALIVE_INTERVAL,
|
494 |
+
return_when=asyncio.FIRST_COMPLETED
|
495 |
+
)
|
496 |
+
|
497 |
+
# 如果没有任务完成,发送保活消息
|
498 |
+
if not done:
|
499 |
+
keepalive_counter += 1
|
500 |
+
|
501 |
+
# 发送简单的换行符作为保活消息
|
502 |
+
yield "\n"
|
503 |
+
continue
|
504 |
+
|
505 |
+
# 检查已完成的任务是否成功
|
506 |
+
for task in done:
|
507 |
+
api_key = tasks_map[task]
|
508 |
+
try:
|
509 |
+
status = task.result()
|
510 |
+
# 如果有成功响应内容
|
511 |
+
if status == "success" :
|
512 |
+
success = True
|
513 |
+
log('info', f"非流式请求成功",
|
514 |
+
extra={'key': api_key[:8],'request_type': 'non-stream', 'model': chat_request.model})
|
515 |
+
cached_response, cache_hit = await response_cache_manager.get_and_remove(cache_key)
|
516 |
+
|
517 |
+
# 发送最终的非流式响应
|
518 |
+
if is_gemini:
|
519 |
+
final_response = cached_response.data
|
520 |
+
else:
|
521 |
+
final_response = openAI_from_Gemini(cached_response, stream=False)
|
522 |
+
|
523 |
+
# 将非流式响应作为字符串发送
|
524 |
+
yield json.dumps(final_response, ensure_ascii=False)
|
525 |
+
return
|
526 |
+
elif status == "empty":
|
527 |
+
# 增加空响应计数
|
528 |
+
empty_response_count += 1
|
529 |
+
log('warning', f"空响应计数: {empty_response_count}/{settings.MAX_EMPTY_RESPONSES}",
|
530 |
+
extra={'key': api_key[:8], 'request_type': 'non-stream', 'model': chat_request.model})
|
531 |
+
|
532 |
+
except Exception as e:
|
533 |
+
handle_gemini_error(e, api_key)
|
534 |
+
|
535 |
+
# 更新任务列表,移除已完成的任务
|
536 |
+
tasks = [(k, t) for k, t in tasks if not t.done()]
|
537 |
+
|
538 |
+
# 如果当前批次没有成功响应,并且还有密钥可用,则继续尝试
|
539 |
+
if not success and valid_keys:
|
540 |
+
# 增加并发数,但不超过最大并发数
|
541 |
+
current_concurrent = min(current_concurrent + settings.INCREASE_CONCURRENT_ON_FAILURE, settings.MAX_CONCURRENT_REQUESTS)
|
542 |
+
log('info', f"所有并发请求失败或返回空响应,增加并发数至: {current_concurrent}",
|
543 |
+
extra={'request_type': 'non-stream', 'model': chat_request.model})
|
544 |
+
|
545 |
+
# 如果空响应次数达到限制,跳出循环,并返回酒馆正常响应(包含错误信息)
|
546 |
+
if empty_response_count >= settings.MAX_EMPTY_RESPONSES:
|
547 |
+
log('warning', f"空响应次数达到限制 ({empty_response_count}/{settings.MAX_EMPTY_RESPONSES}),停止轮询",
|
548 |
+
extra={'request_type': 'non-stream', 'model': chat_request.model})
|
549 |
+
|
550 |
+
if is_gemini :
|
551 |
+
error_response = gemini_from_text(content="空响应次数达到上限\n请修改输入提示词", finish_reason="STOP", stream=False)
|
552 |
+
else:
|
553 |
+
error_response = openAI_from_text(model=chat_request.model, content="空响应次数达到上限\n请修改输入提示词", finish_reason="stop", stream=False)
|
554 |
+
|
555 |
+
yield json.dumps(error_response, ensure_ascii=False)
|
556 |
+
return
|
557 |
+
|
558 |
+
# 如果所有尝试都失败
|
559 |
+
log('error', "API key 替换失败,所有API key都已尝试,请重新配置或稍后重试", extra={'request_type': 'switch_key'})
|
560 |
+
|
561 |
+
if is_gemini:
|
562 |
+
error_response = gemini_from_text(content="所有API密钥均请求失败\n具体错误请查看轮询日志", finish_reason="STOP", stream=False)
|
563 |
+
else:
|
564 |
+
error_response = openAI_from_text(model=chat_request.model, content="所有API密钥均请求失败\n具体错误请查看轮询日志", finish_reason="stop", stream=False)
|
565 |
+
|
566 |
+
yield json.dumps(error_response, ensure_ascii=False)
|
567 |
+
|
568 |
+
except Exception as e:
|
569 |
+
log('error', f"保活流式处理出错: {str(e)}",
|
570 |
+
extra={'request_type': 'non-stream', 'keepalive': True})
|
571 |
+
raise
|
572 |
+
|
573 |
+
# 返回流式响应,但使用application/json媒体类型
|
574 |
+
return StreamingResponse(
|
575 |
+
keepalive_stream_generator(),
|
576 |
+
media_type="application/json"
|
577 |
+
)
|
app/api/routes.py
ADDED
@@ -0,0 +1,336 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import json
|
2 |
+
from typing import Optional, Union
|
3 |
+
from fastapi import APIRouter, Body, HTTPException, Path, Query, Request, Depends, status, Header
|
4 |
+
from fastapi.responses import StreamingResponse
|
5 |
+
from app.services import GeminiClient
|
6 |
+
from app.utils import protect_from_abuse,generate_cache_key,openAI_from_text,log
|
7 |
+
from app.utils.response import openAI_from_Gemini
|
8 |
+
from app.utils.auth import custom_verify_password
|
9 |
+
from .stream_handlers import process_stream_request
|
10 |
+
from .nonstream_handlers import process_request, process_nonstream_with_keepalive_stream
|
11 |
+
from app.models.schemas import ChatCompletionRequest, ChatCompletionResponse, ModelList, AIRequest, ChatRequestGemini
|
12 |
+
import app.config.settings as settings
|
13 |
+
import asyncio
|
14 |
+
from app.vertex.routes import chat_api, models_api
|
15 |
+
from app.vertex.models import OpenAIRequest, OpenAIMessage
|
16 |
+
|
17 |
+
# 创建路由器
|
18 |
+
router = APIRouter()
|
19 |
+
|
20 |
+
# 全局变量引用 - 这些将在main.py中初始化并传递给路由
|
21 |
+
key_manager = None
|
22 |
+
response_cache_manager = None
|
23 |
+
active_requests_manager = None
|
24 |
+
safety_settings = None
|
25 |
+
safety_settings_g2 = None
|
26 |
+
current_api_key = None
|
27 |
+
FAKE_STREAMING = None
|
28 |
+
FAKE_STREAMING_INTERVAL = None
|
29 |
+
PASSWORD = None
|
30 |
+
MAX_REQUESTS_PER_MINUTE = None
|
31 |
+
MAX_REQUESTS_PER_DAY_PER_IP = None
|
32 |
+
|
33 |
+
# 初始化路由器的函数
|
34 |
+
def init_router(
|
35 |
+
_key_manager,
|
36 |
+
_response_cache_manager,
|
37 |
+
_active_requests_manager,
|
38 |
+
_safety_settings,
|
39 |
+
_safety_settings_g2,
|
40 |
+
_current_api_key,
|
41 |
+
_fake_streaming,
|
42 |
+
_fake_streaming_interval,
|
43 |
+
_password,
|
44 |
+
_max_requests_per_minute,
|
45 |
+
_max_requests_per_day_per_ip
|
46 |
+
):
|
47 |
+
global key_manager, response_cache_manager, active_requests_manager
|
48 |
+
global safety_settings, safety_settings_g2, current_api_key
|
49 |
+
global FAKE_STREAMING, FAKE_STREAMING_INTERVAL
|
50 |
+
global PASSWORD, MAX_REQUESTS_PER_MINUTE, MAX_REQUESTS_PER_DAY_PER_IP
|
51 |
+
|
52 |
+
key_manager = _key_manager
|
53 |
+
response_cache_manager = _response_cache_manager
|
54 |
+
active_requests_manager = _active_requests_manager
|
55 |
+
safety_settings = _safety_settings
|
56 |
+
safety_settings_g2 = _safety_settings_g2
|
57 |
+
current_api_key = _current_api_key
|
58 |
+
FAKE_STREAMING = _fake_streaming
|
59 |
+
FAKE_STREAMING_INTERVAL = _fake_streaming_interval
|
60 |
+
PASSWORD = _password
|
61 |
+
MAX_REQUESTS_PER_MINUTE = _max_requests_per_minute
|
62 |
+
MAX_REQUESTS_PER_DAY_PER_IP = _max_requests_per_day_per_ip
|
63 |
+
|
64 |
+
async def verify_user_agent(request: Request):
|
65 |
+
if not settings.WHITELIST_USER_AGENT:
|
66 |
+
return
|
67 |
+
if request.headers.get("User-Agent") not in settings.WHITELIST_USER_AGENT:
|
68 |
+
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not allowed client")
|
69 |
+
|
70 |
+
# todo : 添加 gemini 支持(流式返回)
|
71 |
+
async def get_cache(cache_key,is_stream: bool,is_gemini=False):
|
72 |
+
# 检查缓存是否存在,如果存在,返回缓存
|
73 |
+
cached_response, cache_hit = await response_cache_manager.get_and_remove(cache_key)
|
74 |
+
|
75 |
+
if cache_hit and cached_response:
|
76 |
+
log('info', f"缓存命中: {cache_key[:8]}...",
|
77 |
+
extra={'request_type': 'non-stream', 'model': cached_response.model})
|
78 |
+
|
79 |
+
if is_gemini:
|
80 |
+
if is_stream:
|
81 |
+
data = f"data: {json.dumps(cached_response.data, ensure_ascii=False)}\n\n"
|
82 |
+
return StreamingResponse(data, media_type="text/event-stream")
|
83 |
+
else:
|
84 |
+
return cached_response.data
|
85 |
+
|
86 |
+
|
87 |
+
if is_stream:
|
88 |
+
chunk = openAI_from_Gemini(cached_response,stream=True)
|
89 |
+
return StreamingResponse(chunk, media_type="text/event-stream")
|
90 |
+
else:
|
91 |
+
return openAI_from_Gemini(cached_response,stream=False)
|
92 |
+
|
93 |
+
return None
|
94 |
+
|
95 |
+
@router.get("/aistudio/models",response_model=ModelList)
|
96 |
+
async def aistudio_list_models(_ = Depends(custom_verify_password),
|
97 |
+
_2 = Depends(verify_user_agent)):
|
98 |
+
if settings.WHITELIST_MODELS:
|
99 |
+
filtered_models = [model for model in GeminiClient.AVAILABLE_MODELS if model in settings.WHITELIST_MODELS]
|
100 |
+
else:
|
101 |
+
filtered_models = [model for model in GeminiClient.AVAILABLE_MODELS if model not in settings.BLOCKED_MODELS]
|
102 |
+
return ModelList(data=[{"id": model, "object": "model", "created": 1678888888, "owned_by": "organization-owner"} for model in filtered_models])
|
103 |
+
|
104 |
+
@router.get("/vertex/models",response_model=ModelList)
|
105 |
+
async def vertex_list_models(request: Request,
|
106 |
+
_ = Depends(custom_verify_password),
|
107 |
+
_2 = Depends(verify_user_agent)):
|
108 |
+
# 使用vertex/routes/models_api的实现
|
109 |
+
return await models_api.list_models(request, current_api_key)
|
110 |
+
|
111 |
+
# API路由
|
112 |
+
@router.get("/v1/models",response_model=ModelList)
|
113 |
+
@router.get("/models",response_model=ModelList)
|
114 |
+
async def list_models(request: Request,
|
115 |
+
_ = Depends(custom_verify_password),
|
116 |
+
_2 = Depends(verify_user_agent)):
|
117 |
+
if settings.ENABLE_VERTEX:
|
118 |
+
return await vertex_list_models(request, _, _2)
|
119 |
+
return await aistudio_list_models(_, _2)
|
120 |
+
|
121 |
+
@router.post("/aistudio/chat/completions", response_model=ChatCompletionResponse)
|
122 |
+
async def aistudio_chat_completions(
|
123 |
+
request: Union[ChatCompletionRequest, AIRequest],
|
124 |
+
http_request: Request,
|
125 |
+
_ = Depends(custom_verify_password),
|
126 |
+
_2 = Depends(verify_user_agent),
|
127 |
+
):
|
128 |
+
format_type = getattr(request, 'format_type', None)
|
129 |
+
if format_type and (format_type == "gemini"):
|
130 |
+
is_gemini = True
|
131 |
+
else:
|
132 |
+
is_gemini = False
|
133 |
+
|
134 |
+
# 生成缓存键 - 用于匹配请求内容对应缓存
|
135 |
+
if settings.PRECISE_CACHE:
|
136 |
+
cache_key = generate_cache_key(request, is_gemini = is_gemini)
|
137 |
+
else:
|
138 |
+
cache_key = generate_cache_key(request, last_n_messages = settings.CALCULATE_CACHE_ENTRIES,is_gemini = is_gemini)
|
139 |
+
|
140 |
+
# 请求前基本检查
|
141 |
+
await protect_from_abuse(
|
142 |
+
http_request,
|
143 |
+
settings.MAX_REQUESTS_PER_MINUTE,
|
144 |
+
settings.MAX_REQUESTS_PER_DAY_PER_IP)
|
145 |
+
|
146 |
+
if request.model not in GeminiClient.AVAILABLE_MODELS:
|
147 |
+
log('error', "无效的模型",
|
148 |
+
extra={'model': request.model, 'status_code': 400})
|
149 |
+
raise HTTPException(
|
150 |
+
status_code=status.HTTP_400_BAD_REQUEST, detail="无效的模型")
|
151 |
+
|
152 |
+
|
153 |
+
# 记录请求缓存键信息
|
154 |
+
log('info', f"请求缓存键: {cache_key[:8]}...",
|
155 |
+
extra={'request_type': 'non-stream', 'model': request.model})
|
156 |
+
|
157 |
+
# 检查缓存是否存在,如果存在,返回缓存
|
158 |
+
cached_response = await get_cache(cache_key, is_stream = request.stream,is_gemini=is_gemini)
|
159 |
+
if cached_response :
|
160 |
+
return cached_response
|
161 |
+
|
162 |
+
if not settings.PUBLIC_MODE:
|
163 |
+
# 构建包含缓存键的活跃请求池键
|
164 |
+
pool_key = f"{cache_key}"
|
165 |
+
|
166 |
+
# 查找所有使用相同缓存键的活跃任务
|
167 |
+
active_task = active_requests_manager.get(pool_key)
|
168 |
+
if active_task and not active_task.done():
|
169 |
+
log('info', f"发现相同请求的进行中任务",
|
170 |
+
extra={'request_type': 'stream' if request.stream else "non-stream", 'model': request.model})
|
171 |
+
|
172 |
+
# 等待已有任务完成
|
173 |
+
try:
|
174 |
+
# 设置超时,避免无限等待
|
175 |
+
await asyncio.wait_for(active_task, timeout=240)
|
176 |
+
|
177 |
+
# 使用任务结果
|
178 |
+
if active_task.done() and not active_task.cancelled():
|
179 |
+
|
180 |
+
result = active_task.result()
|
181 |
+
active_requests_manager.remove(pool_key)
|
182 |
+
if result:
|
183 |
+
return result
|
184 |
+
|
185 |
+
except (asyncio.TimeoutError, asyncio.CancelledError) as e:
|
186 |
+
# 任务超时或被取消的情况下,记录日志然后让代码继续执行
|
187 |
+
error_type = "超时" if isinstance(e, asyncio.TimeoutError) else "被取消"
|
188 |
+
log('warning', f"等待已有任务{error_type}: {pool_key}",
|
189 |
+
extra={'request_type': 'non-stream', 'model': request.model})
|
190 |
+
|
191 |
+
# 从活跃请求池移除该任务
|
192 |
+
if active_task.done() or active_task.cancelled():
|
193 |
+
active_requests_manager.remove(pool_key)
|
194 |
+
log('info', f"已从活跃请求池移除{error_type}任务: {pool_key}",
|
195 |
+
extra={'request_type': 'non-stream'})
|
196 |
+
|
197 |
+
|
198 |
+
if request.stream:
|
199 |
+
# 流式请求处理任务
|
200 |
+
process_task = asyncio.create_task(
|
201 |
+
process_stream_request(
|
202 |
+
chat_request = request,
|
203 |
+
key_manager=key_manager,
|
204 |
+
response_cache_manager = response_cache_manager,
|
205 |
+
safety_settings = safety_settings,
|
206 |
+
safety_settings_g2 = safety_settings_g2,
|
207 |
+
cache_key = cache_key
|
208 |
+
)
|
209 |
+
)
|
210 |
+
|
211 |
+
else:
|
212 |
+
# 检查是否启用非流式保活功能
|
213 |
+
if settings.NONSTREAM_KEEPALIVE_ENABLED:
|
214 |
+
# 使用带保活功能的非流式请求处理
|
215 |
+
process_task = asyncio.create_task(
|
216 |
+
process_nonstream_with_keepalive_stream(
|
217 |
+
chat_request = request,
|
218 |
+
key_manager = key_manager,
|
219 |
+
response_cache_manager = response_cache_manager,
|
220 |
+
safety_settings = safety_settings,
|
221 |
+
safety_settings_g2 = safety_settings_g2,
|
222 |
+
cache_key = cache_key,
|
223 |
+
is_gemini = is_gemini
|
224 |
+
)
|
225 |
+
)
|
226 |
+
else:
|
227 |
+
# 创建非流式请求处理任务
|
228 |
+
process_task = asyncio.create_task(
|
229 |
+
process_request(
|
230 |
+
chat_request = request,
|
231 |
+
key_manager = key_manager,
|
232 |
+
response_cache_manager = response_cache_manager,
|
233 |
+
safety_settings = safety_settings,
|
234 |
+
safety_settings_g2 = safety_settings_g2,
|
235 |
+
cache_key = cache_key
|
236 |
+
)
|
237 |
+
)
|
238 |
+
|
239 |
+
if not settings.PUBLIC_MODE:
|
240 |
+
# 将任务添加到活跃请求池
|
241 |
+
active_requests_manager.add(pool_key, process_task)
|
242 |
+
|
243 |
+
# 等待任务完成
|
244 |
+
try:
|
245 |
+
response = await process_task
|
246 |
+
if not settings.PUBLIC_MODE:
|
247 |
+
active_requests_manager.remove(pool_key)
|
248 |
+
|
249 |
+
return response
|
250 |
+
except Exception as e:
|
251 |
+
if not settings.PUBLIC_MODE:
|
252 |
+
# 如果任务失败,从活跃请求池中移除
|
253 |
+
active_requests_manager.remove(pool_key)
|
254 |
+
|
255 |
+
# 检查是否已有缓存的结果(可能是由另一个任务创建的)
|
256 |
+
cached_response = await get_cache(cache_key, is_stream = request.stream,is_gemini=is_gemini)
|
257 |
+
if cached_response :
|
258 |
+
return cached_response
|
259 |
+
|
260 |
+
# 发送错误信息给客户端
|
261 |
+
raise HTTPException(status_code=500, detail=f" hajimi 服务器内部处理时发生错误\n具体原因:{e}")
|
262 |
+
|
263 |
+
@router.post("/vertex/chat/completions", response_model=ChatCompletionResponse)
|
264 |
+
async def vertex_chat_completions(
|
265 |
+
request: ChatCompletionRequest,
|
266 |
+
http_request: Request,
|
267 |
+
_dp = Depends(custom_verify_password),
|
268 |
+
_du = Depends(verify_user_agent),
|
269 |
+
):
|
270 |
+
# 使用vertex/routes/chat_api的实现
|
271 |
+
|
272 |
+
# 转换消息格式
|
273 |
+
openai_messages = []
|
274 |
+
for message in request.messages:
|
275 |
+
openai_messages.append(OpenAIMessage(
|
276 |
+
role=message.get('role', ''),
|
277 |
+
content=message.get('content', '')
|
278 |
+
))
|
279 |
+
|
280 |
+
# 转换请求格式
|
281 |
+
vertex_request = OpenAIRequest(
|
282 |
+
model=request.model,
|
283 |
+
messages=openai_messages,
|
284 |
+
temperature=request.temperature,
|
285 |
+
max_tokens=request.max_tokens,
|
286 |
+
top_p=request.top_p,
|
287 |
+
top_k=request.top_k,
|
288 |
+
stream=request.stream,
|
289 |
+
stop=request.stop,
|
290 |
+
presence_penalty=request.presence_penalty,
|
291 |
+
frequency_penalty=request.frequency_penalty,
|
292 |
+
seed=getattr(request, 'seed', None),
|
293 |
+
logprobs=getattr(request, 'logprobs', None),
|
294 |
+
response_logprobs=getattr(request, 'response_logprobs', None),
|
295 |
+
n=request.n
|
296 |
+
)
|
297 |
+
|
298 |
+
# 调用vertex/routes/chat_api的实现
|
299 |
+
return await chat_api.chat_completions(http_request, vertex_request, current_api_key)
|
300 |
+
|
301 |
+
@router.post("/v1/chat/completions", response_model=ChatCompletionResponse)
|
302 |
+
@router.post("/chat/completions", response_model=ChatCompletionResponse)
|
303 |
+
async def chat_completions(
|
304 |
+
request: ChatCompletionRequest,
|
305 |
+
http_request: Request,
|
306 |
+
_dp = Depends(custom_verify_password),
|
307 |
+
_du = Depends(verify_user_agent),
|
308 |
+
):
|
309 |
+
"""处理API请求的主函数,根据需要处理流式或非流式请求"""
|
310 |
+
if settings.ENABLE_VERTEX:
|
311 |
+
return await vertex_chat_completions(request, http_request, _dp, _du)
|
312 |
+
return await aistudio_chat_completions(request, http_request, _dp, _du)
|
313 |
+
|
314 |
+
@router.post("/gemini/{api_version:str}/models/{model_and_responseType:path}")
|
315 |
+
async def gemini_chat_completions(
|
316 |
+
request: Request,
|
317 |
+
model_and_responseType: str = Path(...),
|
318 |
+
key: Optional[str] = Query(None),
|
319 |
+
alt: Optional[str] = Query(None, description=" sse 或 None"),
|
320 |
+
payload: ChatRequestGemini = Body(...),
|
321 |
+
_dp = Depends(custom_verify_password),
|
322 |
+
_du = Depends(verify_user_agent),
|
323 |
+
):
|
324 |
+
# 提取路径参数
|
325 |
+
is_stream = False
|
326 |
+
try:
|
327 |
+
model_name, action_type = model_and_responseType.split(":", 1)
|
328 |
+
if action_type == "streamGenerateContent":
|
329 |
+
is_stream = True
|
330 |
+
|
331 |
+
except ValueError:
|
332 |
+
raise HTTPException(status_code=400, detail="无效的请求路径")
|
333 |
+
|
334 |
+
geminiRequest = AIRequest(payload=payload,model=model_name,stream=is_stream,format_type='gemini')
|
335 |
+
return await aistudio_chat_completions(geminiRequest, request, _dp, _du)
|
336 |
+
|
app/api/stream_handlers.py
ADDED
@@ -0,0 +1,374 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import asyncio
|
2 |
+
import json
|
3 |
+
from fastapi.responses import StreamingResponse
|
4 |
+
from app.models.schemas import ChatCompletionRequest
|
5 |
+
from app.services import GeminiClient
|
6 |
+
from app.utils import handle_gemini_error, update_api_call_stats,log,openAI_from_text
|
7 |
+
from app.utils.response import openAI_from_Gemini,gemini_from_text
|
8 |
+
from app.utils.stats import get_api_key_usage
|
9 |
+
import app.config.settings as settings
|
10 |
+
|
11 |
+
async def stream_response_generator(
|
12 |
+
chat_request,
|
13 |
+
key_manager,
|
14 |
+
response_cache_manager,
|
15 |
+
safety_settings,
|
16 |
+
safety_settings_g2,
|
17 |
+
cache_key: str
|
18 |
+
):
|
19 |
+
format_type = getattr(chat_request, 'format_type', None)
|
20 |
+
if format_type and (format_type == "gemini"):
|
21 |
+
is_gemini = True
|
22 |
+
contents, system_instruction = None,None
|
23 |
+
else:
|
24 |
+
is_gemini = False
|
25 |
+
# 转换消息格式
|
26 |
+
contents, system_instruction = GeminiClient.convert_messages(GeminiClient, chat_request.messages,model=chat_request.model)
|
27 |
+
# 设置初始并发数
|
28 |
+
current_concurrent = settings.CONCURRENT_REQUESTS
|
29 |
+
max_retry_num = settings.MAX_RETRY_NUM
|
30 |
+
|
31 |
+
# 当前请求次数
|
32 |
+
current_try_num = 0
|
33 |
+
|
34 |
+
# 空响应计数
|
35 |
+
empty_response_count = 0
|
36 |
+
|
37 |
+
# (假流式) 尝试使用不同API密钥,直到达到最大重试次数或空响应限制
|
38 |
+
while (settings.FAKE_STREAMING and (current_try_num < max_retry_num) and (empty_response_count < settings.MAX_EMPTY_RESPONSES)):
|
39 |
+
# 获取当前批次的密钥数量
|
40 |
+
batch_num = min(max_retry_num - current_try_num, current_concurrent)
|
41 |
+
|
42 |
+
# 获取当前批次的密钥
|
43 |
+
valid_keys = []
|
44 |
+
checked_keys = set() # 用于记录已检查过的密钥
|
45 |
+
all_keys_checked = False # 标记是否已检查所有密钥
|
46 |
+
|
47 |
+
# 尝试获取足够数量的有效密钥
|
48 |
+
while len(valid_keys) < batch_num:
|
49 |
+
api_key = await key_manager.get_available_key()
|
50 |
+
if not api_key:
|
51 |
+
break
|
52 |
+
|
53 |
+
# 如果这个密钥已经检查过,说明已经检查了所有密钥
|
54 |
+
if api_key in checked_keys:
|
55 |
+
all_keys_checked = True
|
56 |
+
break
|
57 |
+
|
58 |
+
checked_keys.add(api_key)
|
59 |
+
# 获取API密钥的调用次数
|
60 |
+
usage = await get_api_key_usage(settings.api_call_stats, api_key)
|
61 |
+
# 如果调用次数小于限制,则添加到有效密钥列表
|
62 |
+
if usage < settings.API_KEY_DAILY_LIMIT:
|
63 |
+
valid_keys.append(api_key)
|
64 |
+
else:
|
65 |
+
log('warning', f"API密钥 {api_key[:8]}... 已达到每日调用限制 ({usage}/{settings.API_KEY_DAILY_LIMIT})",
|
66 |
+
extra={'key': api_key[:8], 'request_type': 'stream', 'model': chat_request.model})
|
67 |
+
|
68 |
+
# 如果已经检查了所有密钥且没有找到有效密钥,则重置密钥栈
|
69 |
+
if all_keys_checked and not valid_keys:
|
70 |
+
log('warning', "所有API密钥已达到每日调用限制,重置密钥栈",
|
71 |
+
extra={'request_type': 'stream', 'model': chat_request.model})
|
72 |
+
key_manager._reset_key_stack()
|
73 |
+
# 重置后重新获取一个密钥
|
74 |
+
api_key = await key_manager.get_available_key()
|
75 |
+
if api_key:
|
76 |
+
valid_keys = [api_key]
|
77 |
+
|
78 |
+
# 如果没有获取到任何有效密钥,跳出循环
|
79 |
+
if not valid_keys:
|
80 |
+
break
|
81 |
+
|
82 |
+
# 更新当前尝试次数
|
83 |
+
current_try_num += len(valid_keys)
|
84 |
+
|
85 |
+
# 创建并发任务
|
86 |
+
tasks = []
|
87 |
+
tasks_map = {}
|
88 |
+
for api_key in valid_keys:
|
89 |
+
# 假流式模式的处理逻辑
|
90 |
+
log('info', f"假流式请求开始,使用密钥: {api_key[:8]}...",
|
91 |
+
extra={'key': api_key[:8], 'request_type': 'fake-stream', 'model': chat_request.model})
|
92 |
+
|
93 |
+
task = asyncio.create_task(
|
94 |
+
handle_fake_streaming(
|
95 |
+
api_key,
|
96 |
+
chat_request,
|
97 |
+
contents,
|
98 |
+
response_cache_manager,
|
99 |
+
system_instruction,
|
100 |
+
safety_settings,
|
101 |
+
safety_settings_g2,
|
102 |
+
cache_key
|
103 |
+
)
|
104 |
+
)
|
105 |
+
|
106 |
+
tasks.append((api_key, task))
|
107 |
+
tasks_map[task] = api_key
|
108 |
+
|
109 |
+
# 等待所有任务完成或找到成功响应
|
110 |
+
success = False
|
111 |
+
while tasks and not success:
|
112 |
+
# 等待任务完成
|
113 |
+
done, pending = await asyncio.wait(
|
114 |
+
[task for _, task in tasks],
|
115 |
+
timeout=settings.FAKE_STREAMING_INTERVAL,
|
116 |
+
return_when=asyncio.FIRST_COMPLETED
|
117 |
+
)
|
118 |
+
|
119 |
+
# 如果没有任务完成,发送保活消息
|
120 |
+
if not done :
|
121 |
+
if is_gemini:
|
122 |
+
yield gemini_from_text(content='',stream=True)
|
123 |
+
else:
|
124 |
+
yield openAI_from_text(model=chat_request.model,content='',stream=True)
|
125 |
+
continue
|
126 |
+
|
127 |
+
# 检查已完成的任务是否成功
|
128 |
+
for task in done:
|
129 |
+
api_key = tasks_map[task]
|
130 |
+
if not task.cancelled():
|
131 |
+
try:
|
132 |
+
status = task.result()
|
133 |
+
# 如果有成功响应内容
|
134 |
+
if status == "success" :
|
135 |
+
success = True
|
136 |
+
log('info', f"假流式请求成功",
|
137 |
+
extra={'key': api_key[:8],'request_type': "fake-stream", 'model': chat_request.model})
|
138 |
+
cached_response, cache_hit = await response_cache_manager.get_and_remove(cache_key)
|
139 |
+
if cache_hit and cached_response:
|
140 |
+
if is_gemini :
|
141 |
+
json_payload = json.dumps(cached_response.data, ensure_ascii=False)
|
142 |
+
data_to_yield = f"data: {json_payload}\n\n"
|
143 |
+
yield data_to_yield
|
144 |
+
else:
|
145 |
+
yield openAI_from_Gemini(cached_response,stream=True)
|
146 |
+
else:
|
147 |
+
success = False
|
148 |
+
break
|
149 |
+
elif status == "empty":
|
150 |
+
# 增加空响应计数
|
151 |
+
empty_response_count += 1
|
152 |
+
log('warning', f"空响应计数: {empty_response_count}/{settings.MAX_EMPTY_RESPONSES}",
|
153 |
+
extra={'key': api_key[:8], 'request_type': 'stream', 'model': chat_request.model})
|
154 |
+
|
155 |
+
except Exception as e:
|
156 |
+
error_detail = handle_gemini_error(e, api_key)
|
157 |
+
log('error', f"请求失败: {error_detail}",
|
158 |
+
extra={'key': api_key[:8], 'request_type': 'stream', 'model': chat_request.model})
|
159 |
+
|
160 |
+
# 如果找到成功的响应,跳出循环
|
161 |
+
if success:
|
162 |
+
return
|
163 |
+
|
164 |
+
# 如果空响应次数达到限制,跳出循环
|
165 |
+
if empty_response_count >= settings.MAX_EMPTY_RESPONSES:
|
166 |
+
log('warning', f"空响应次数达到限制 ({empty_response_count}/{settings.MAX_EMPTY_RESPONSES}),停止轮询",
|
167 |
+
extra={'request_type': 'fake-stream', 'model': chat_request.model})
|
168 |
+
if is_gemini :
|
169 |
+
yield gemini_from_text(content="空响应次数达到上限\n请修改输入提示词",finish_reason="STOP",stream=True)
|
170 |
+
else:
|
171 |
+
yield openAI_from_text(model=chat_request.model,content="空响应次数达到上限\n请修改输入提示词",finish_reason="stop",stream=True)
|
172 |
+
|
173 |
+
return
|
174 |
+
|
175 |
+
# 更新任务列表,移除已完成的任务
|
176 |
+
tasks = [(k, t) for k, t in tasks if not t.done()]
|
177 |
+
|
178 |
+
# 如果所有请求都失败,增加并发数并继续尝试
|
179 |
+
if not success and valid_keys:
|
180 |
+
# 增加并发数,但不超过最大并发数
|
181 |
+
current_concurrent = min(current_concurrent + settings.INCREASE_CONCURRENT_ON_FAILURE, settings.MAX_CONCURRENT_REQUESTS)
|
182 |
+
log('info', f"所有假流式请求失败,增加并发数至: {current_concurrent}",
|
183 |
+
extra={'request_type': 'stream', 'model': chat_request.model})
|
184 |
+
|
185 |
+
# (真流式) 尝试使用不同API密钥,直到达到最大重试次数或空响应限制
|
186 |
+
while (not settings.FAKE_STREAMING and (current_try_num < max_retry_num) and (empty_response_count < settings.MAX_EMPTY_RESPONSES)):
|
187 |
+
# 获取当前批次的密钥
|
188 |
+
valid_keys = []
|
189 |
+
checked_keys = set() # 用于记录已检查过的密钥
|
190 |
+
all_keys_checked = False # 标记是否已检查所有密钥
|
191 |
+
|
192 |
+
# 尝试获取一个有效密钥
|
193 |
+
while len(valid_keys) < 1:
|
194 |
+
api_key = await key_manager.get_available_key()
|
195 |
+
if not api_key:
|
196 |
+
break
|
197 |
+
|
198 |
+
# 如果这个密钥已经检查过,说明已经检查了所有密钥
|
199 |
+
if api_key in checked_keys:
|
200 |
+
all_keys_checked = True
|
201 |
+
break
|
202 |
+
|
203 |
+
checked_keys.add(api_key)
|
204 |
+
# 获取API密钥的调用次数
|
205 |
+
usage = await get_api_key_usage(settings.api_call_stats, api_key)
|
206 |
+
# 如果调用次数小于限制,则添加到有效密钥列表
|
207 |
+
if usage < settings.API_KEY_DAILY_LIMIT:
|
208 |
+
valid_keys.append(api_key)
|
209 |
+
else:
|
210 |
+
log('warning', f"API密钥 {api_key[:8]}... 已达到每日调用限制 ({usage}/{settings.API_KEY_DAILY_LIMIT})",
|
211 |
+
extra={'key': api_key[:8], 'request_type': 'stream', 'model': chat_request.model})
|
212 |
+
|
213 |
+
# 如果已经检查了所有密钥且没有找到有效密钥,则重置密钥栈
|
214 |
+
if all_keys_checked and not valid_keys:
|
215 |
+
log('warning', "所有API密钥已达到每日调用限制,重置密钥栈",
|
216 |
+
extra={'request_type': 'stream', 'model': chat_request.model})
|
217 |
+
key_manager._reset_key_stack()
|
218 |
+
# 重置后重新获取一个密钥
|
219 |
+
api_key = await key_manager.get_available_key()
|
220 |
+
if api_key:
|
221 |
+
valid_keys = [api_key]
|
222 |
+
|
223 |
+
# 如果没有获取到任何有效密钥,跳出循环
|
224 |
+
if not valid_keys:
|
225 |
+
break
|
226 |
+
|
227 |
+
# 更新当前尝试次数
|
228 |
+
current_try_num += 1
|
229 |
+
|
230 |
+
# 获取密钥
|
231 |
+
api_key = valid_keys[0]
|
232 |
+
|
233 |
+
success = False
|
234 |
+
try:
|
235 |
+
client = GeminiClient(api_key)
|
236 |
+
|
237 |
+
# 获取流式响应
|
238 |
+
stream_generator = client.stream_chat(
|
239 |
+
chat_request,
|
240 |
+
contents,
|
241 |
+
safety_settings_g2 if 'gemini-2.5' in chat_request.model else safety_settings,
|
242 |
+
system_instruction
|
243 |
+
)
|
244 |
+
token=0
|
245 |
+
# 处理流式响应
|
246 |
+
async for chunk in stream_generator:
|
247 |
+
if chunk :
|
248 |
+
|
249 |
+
if chunk.total_token_count:
|
250 |
+
token = int(chunk.total_token_count)
|
251 |
+
success = True
|
252 |
+
|
253 |
+
if is_gemini:
|
254 |
+
json_payload = json.dumps(chunk.data, ensure_ascii=False)
|
255 |
+
data = f"data: {json_payload}\n\n"
|
256 |
+
else:
|
257 |
+
data = openAI_from_Gemini(chunk,stream=True)
|
258 |
+
|
259 |
+
# log('info', f"流式响应发送数据: {data}")
|
260 |
+
yield data
|
261 |
+
|
262 |
+
else:
|
263 |
+
log('warning', f"流式请求返回空响应,空响应计数: {empty_response_count}/{settings.MAX_EMPTY_RESPONSES}",
|
264 |
+
extra={'key': api_key[:8], 'request_type': 'stream', 'model': chat_request.model})
|
265 |
+
# 增加空响应计数
|
266 |
+
empty_response_count += 1
|
267 |
+
await update_api_call_stats(
|
268 |
+
settings.api_call_stats,
|
269 |
+
endpoint=api_key,
|
270 |
+
model=chat_request.model,
|
271 |
+
token=token
|
272 |
+
)
|
273 |
+
break
|
274 |
+
|
275 |
+
except Exception as e:
|
276 |
+
error_detail = handle_gemini_error(e, api_key)
|
277 |
+
log('error', f"流式响应: API密钥 {api_key[:8]}... 请求失败: {error_detail}",
|
278 |
+
extra={'key': api_key[:8], 'request_type': 'stream', 'model': chat_request.model})
|
279 |
+
finally:
|
280 |
+
# 如果成功获取相应,更新API调用统计
|
281 |
+
if success:
|
282 |
+
await update_api_call_stats(
|
283 |
+
settings.api_call_stats,
|
284 |
+
endpoint=api_key,
|
285 |
+
model=chat_request.model,
|
286 |
+
token=token
|
287 |
+
)
|
288 |
+
return
|
289 |
+
|
290 |
+
# 如果空响应次数达到限制,跳出循环
|
291 |
+
if empty_response_count >= settings.MAX_EMPTY_RESPONSES:
|
292 |
+
|
293 |
+
log('warning', f"空响应次数达到限制 ({empty_response_count}/{settings.MAX_EMPTY_RESPONSES}),停止轮询",
|
294 |
+
extra={'request_type': 'stream', 'model': chat_request.model})
|
295 |
+
|
296 |
+
if is_gemini:
|
297 |
+
yield gemini_from_text(content="空响应次数达到上限\n请修改输入提示词",finish_reason="STOP",stream=True)
|
298 |
+
else:
|
299 |
+
yield openAI_from_text(model=chat_request.model,content="空响应次数达到上限\n请修改输入提示词",finish_reason="stop",stream=True)
|
300 |
+
|
301 |
+
return
|
302 |
+
|
303 |
+
# 所有API密钥都尝试失败的处理
|
304 |
+
log('error', "所有 API 密钥均请求失败,请稍后重试",
|
305 |
+
extra={'key': 'ALL', 'request_type': 'stream', 'model': chat_request.model})
|
306 |
+
|
307 |
+
if is_gemini:
|
308 |
+
yield gemini_from_text(content="所有API密钥均请求失败\n具体错误请查看轮询日志",finish_reason="STOP",stream=True)
|
309 |
+
else:
|
310 |
+
yield openAI_from_text(model=chat_request.model,content="所有API密钥均请求失败\n具体错误请查看轮询日志",finish_reason="stop")
|
311 |
+
|
312 |
+
# 处理假流式模式
|
313 |
+
async def handle_fake_streaming(api_key,chat_request, contents, response_cache_manager,system_instruction, safety_settings, safety_settings_g2, cache_key):
|
314 |
+
|
315 |
+
# 使用非流式请求内容
|
316 |
+
gemini_client = GeminiClient(api_key)
|
317 |
+
|
318 |
+
gemini_task = asyncio.create_task(
|
319 |
+
gemini_client.complete_chat(
|
320 |
+
chat_request,
|
321 |
+
contents,
|
322 |
+
safety_settings_g2 if 'gemini-2.5' in chat_request.model else safety_settings,
|
323 |
+
system_instruction
|
324 |
+
)
|
325 |
+
)
|
326 |
+
gemini_task = asyncio.shield(gemini_task)
|
327 |
+
|
328 |
+
try:
|
329 |
+
# 获取响应内容
|
330 |
+
response_content = await gemini_task
|
331 |
+
response_content.set_model(chat_request.model)
|
332 |
+
log('info', f"假流式成功获取响应,进行缓存",
|
333 |
+
extra={'key': api_key[:8], 'request_type': 'fake-stream', 'model': chat_request.model})
|
334 |
+
|
335 |
+
# 更新API调用统计
|
336 |
+
await update_api_call_stats(settings.api_call_stats, endpoint=api_key, model=chat_request.model,token=response_content.total_token_count)
|
337 |
+
|
338 |
+
# 检查响应内容是否为空
|
339 |
+
if not response_content or (not response_content.text and not response_content.function_call):
|
340 |
+
log('warning', f"请求返回空响应",
|
341 |
+
extra={'key': api_key[:8], 'request_type': 'fake-stream', 'model': chat_request.model})
|
342 |
+
return "empty"
|
343 |
+
|
344 |
+
# 缓存
|
345 |
+
await response_cache_manager.store(cache_key, response_content)
|
346 |
+
return "success"
|
347 |
+
|
348 |
+
except Exception as e:
|
349 |
+
handle_gemini_error(e, api_key)
|
350 |
+
# log('error', f"假流式模式: API密钥 {api_key[:8]}... 请求失败: {error_detail}",
|
351 |
+
# extra={'key': api_key[:8], 'request_type': 'fake-stream', 'model': chat_request.model})
|
352 |
+
return "error"
|
353 |
+
|
354 |
+
|
355 |
+
|
356 |
+
# 流式请求处理函数
|
357 |
+
async def process_stream_request(
|
358 |
+
chat_request: ChatCompletionRequest,
|
359 |
+
key_manager,
|
360 |
+
response_cache_manager,
|
361 |
+
safety_settings,
|
362 |
+
safety_settings_g2,
|
363 |
+
cache_key: str
|
364 |
+
) -> StreamingResponse:
|
365 |
+
"""处理流式API请求"""
|
366 |
+
|
367 |
+
return StreamingResponse(stream_response_generator(
|
368 |
+
chat_request,
|
369 |
+
key_manager,
|
370 |
+
response_cache_manager,
|
371 |
+
safety_settings,
|
372 |
+
safety_settings_g2,
|
373 |
+
cache_key
|
374 |
+
), media_type="text/event-stream")
|
app/config/__init__.py
ADDED
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# 配置模块初始化文件
|
2 |
+
import app.config.settings as settings
|
3 |
+
from app.config.safety import *
|
4 |
+
from app.config.persistence import save_settings, load_settings
|
app/config/persistence.py
ADDED
@@ -0,0 +1,165 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import json
|
2 |
+
import os
|
3 |
+
import inspect
|
4 |
+
import pathlib
|
5 |
+
from app.config import settings
|
6 |
+
from app.utils.logging import log
|
7 |
+
|
8 |
+
# 定义不应该被保存或加载的配置项
|
9 |
+
EXCLUDED_SETTINGS = [
|
10 |
+
"STORAGE_DIR",
|
11 |
+
"ENABLE_STORAGE",
|
12 |
+
"BASE_DIR",
|
13 |
+
"PASSWORD",
|
14 |
+
"WEB_PASSWORD",
|
15 |
+
"WHITELIST_MODELS",
|
16 |
+
"BLOCKED_MODELS",
|
17 |
+
"DEFAULT_BLOCKED_MODELS",
|
18 |
+
"PUBLIC_MODE",
|
19 |
+
"DASHBOARD_URL",
|
20 |
+
"version"
|
21 |
+
]
|
22 |
+
|
23 |
+
def save_settings():
|
24 |
+
"""
|
25 |
+
将settings中所有的从os.environ.get获取的配置保存到JSON文件中,
|
26 |
+
但排除特定的配置项
|
27 |
+
"""
|
28 |
+
if settings.ENABLE_STORAGE:
|
29 |
+
# 确保存储目录存在
|
30 |
+
storage_dir = pathlib.Path(settings.STORAGE_DIR)
|
31 |
+
storage_dir.mkdir(parents=True, exist_ok=True)
|
32 |
+
|
33 |
+
# 设置JSON文件路径
|
34 |
+
settings_file = storage_dir / "settings.json"
|
35 |
+
|
36 |
+
# 获取settings模块中的所有变量
|
37 |
+
settings_dict = {}
|
38 |
+
for name, value in inspect.getmembers(settings):
|
39 |
+
# 跳过内置和私有变量,以及函数/模块/类,以及排除列表中的配置项
|
40 |
+
if (not name.startswith('_') and
|
41 |
+
not inspect.isfunction(value) and
|
42 |
+
not inspect.ismodule(value) and
|
43 |
+
not inspect.isclass(value) and
|
44 |
+
name not in EXCLUDED_SETTINGS):
|
45 |
+
|
46 |
+
# 尝试将可序列化的值添加到字典中
|
47 |
+
try:
|
48 |
+
json.dumps({name: value}) # 测试是否可序列化
|
49 |
+
settings_dict[name] = value
|
50 |
+
except (TypeError, OverflowError):
|
51 |
+
# 如果不可序列化,则跳过
|
52 |
+
continue
|
53 |
+
log('info', f"保存设置到JSON文件: {settings_file}")
|
54 |
+
|
55 |
+
# 保存到JSON文件
|
56 |
+
with open(settings_file, 'w', encoding='utf-8') as f:
|
57 |
+
json.dump(settings_dict, f, ensure_ascii=False, indent=4)
|
58 |
+
|
59 |
+
return settings_file
|
60 |
+
|
61 |
+
def load_settings():
|
62 |
+
"""
|
63 |
+
从JSON文件中加载设置并更新settings模块,
|
64 |
+
排除特定的配置项,并合并GEMINI_API_KEYS
|
65 |
+
"""
|
66 |
+
if settings.ENABLE_STORAGE:
|
67 |
+
# 设置JSON文件路径
|
68 |
+
storage_dir = pathlib.Path(settings.STORAGE_DIR)
|
69 |
+
settings_file = storage_dir / "settings.json"
|
70 |
+
|
71 |
+
# 如果文件不存在,则返回
|
72 |
+
if not settings_file.exists():
|
73 |
+
return False
|
74 |
+
|
75 |
+
# 从JSON文件中加载设置
|
76 |
+
try:
|
77 |
+
with open(settings_file, 'r', encoding='utf-8') as f:
|
78 |
+
loaded_settings = json.load(f)
|
79 |
+
|
80 |
+
# 保存当前环境变量中的GEMINI_API_KEYS
|
81 |
+
current_api_keys = []
|
82 |
+
if hasattr(settings, 'GEMINI_API_KEYS') and settings.GEMINI_API_KEYS:
|
83 |
+
current_api_keys = settings.GEMINI_API_KEYS.split(',')
|
84 |
+
current_api_keys = [key.strip() for key in current_api_keys if key.strip()]
|
85 |
+
|
86 |
+
# 保存当前环境变量中的GOOGLE_CREDENTIALS_JSON和VERTEX_EXPRESS_API_KEY
|
87 |
+
current_google_credentials_json = settings.GOOGLE_CREDENTIALS_JSON if hasattr(settings, 'GOOGLE_CREDENTIALS_JSON') else ""
|
88 |
+
current_vertex_express_api_key = settings.VERTEX_EXPRESS_API_KEY if hasattr(settings, 'VERTEX_EXPRESS_API_KEY') else ""
|
89 |
+
|
90 |
+
# 更新settings模块中的变量,但排除特定配置项
|
91 |
+
for name, value in loaded_settings.items():
|
92 |
+
if hasattr(settings, name) and name not in EXCLUDED_SETTINGS:
|
93 |
+
# 特殊处理GEMINI_API_KEYS,进行合并去重
|
94 |
+
if name == "GEMINI_API_KEYS":
|
95 |
+
loaded_api_keys = value.split(',') if value else []
|
96 |
+
loaded_api_keys = [key.strip() for key in loaded_api_keys if key.strip()]
|
97 |
+
all_keys = list(set(current_api_keys + loaded_api_keys))
|
98 |
+
setattr(settings, name, ','.join(all_keys))
|
99 |
+
# 特殊处理GOOGLE_CREDENTIALS_JSON,如果当前环境变量中有值,则优先使用环境变量中的值
|
100 |
+
elif name == "GOOGLE_CREDENTIALS_JSON":
|
101 |
+
# 检查当前值是否为空(None、空字符串、只有空白字符,或者是"''"这样的空引号)
|
102 |
+
is_empty = (not current_google_credentials_json or
|
103 |
+
not current_google_credentials_json.strip() or
|
104 |
+
current_google_credentials_json.strip() in ['""', "''"])
|
105 |
+
log('debug', f"is_empty检查结果: {is_empty}")
|
106 |
+
if is_empty:
|
107 |
+
log('debug', f"当前GOOGLE_CREDENTIALS_JSON为空,将使用持久化的值")
|
108 |
+
setattr(settings, name, value)
|
109 |
+
# 更新环境变量,确保其他模块能够访问到
|
110 |
+
if value: # 只有当value不为空���才设置环境变量
|
111 |
+
os.environ["GOOGLE_CREDENTIALS_JSON"] = value
|
112 |
+
log('info', f"从持久化存储加载了GOOGLE_CREDENTIALS_JSON配置")
|
113 |
+
else:
|
114 |
+
log('warning', f"持久化的GOOGLE_CREDENTIALS_JSON值为空")
|
115 |
+
else:
|
116 |
+
log('debug', f"当前GOOGLE_CREDENTIALS_JSON不为空,保持现有值")
|
117 |
+
# 特殊处理VERTEX_EXPRESS_API_KEY,如果当前环境变量中有值,则优先使用环境变量中的值
|
118 |
+
elif name == "VERTEX_EXPRESS_API_KEY":
|
119 |
+
# 检查当前值是否为空(None、空字符串或只有空白字符)
|
120 |
+
if not current_vertex_express_api_key or not current_vertex_express_api_key.strip():
|
121 |
+
setattr(settings, name, value)
|
122 |
+
# 更新环境变量,确保其他模块能够访问到
|
123 |
+
if value: # 只有当value不为空时才设置环境变量
|
124 |
+
os.environ["VERTEX_EXPRESS_API_KEY"] = value
|
125 |
+
log('info', f"从持久化存储加载了VERTEX_EXPRESS_API_KEY配置")
|
126 |
+
else:
|
127 |
+
setattr(settings, name, value)
|
128 |
+
|
129 |
+
# 在加载完设置后,检查是否需要刷新模型配置
|
130 |
+
try:
|
131 |
+
# 如果加载了Google Credentials JSON或Vertex Express API Key,需要刷新模型配置
|
132 |
+
if (hasattr(settings, 'GOOGLE_CREDENTIALS_JSON') and settings.GOOGLE_CREDENTIALS_JSON) or \
|
133 |
+
(hasattr(settings, 'VERTEX_EXPRESS_API_KEY') and settings.VERTEX_EXPRESS_API_KEY):
|
134 |
+
log('info', "检测到Google Credentials JSON或Vertex Express API Key,准备更新配置")
|
135 |
+
|
136 |
+
# 更新配置
|
137 |
+
import app.vertex.config as app_config
|
138 |
+
|
139 |
+
# 重新加载vertex配置
|
140 |
+
app_config.reload_config()
|
141 |
+
|
142 |
+
# 更新app_config中的GOOGLE_CREDENTIALS_JSON
|
143 |
+
if hasattr(settings, 'GOOGLE_CREDENTIALS_JSON') and settings.GOOGLE_CREDENTIALS_JSON:
|
144 |
+
app_config.GOOGLE_CREDENTIALS_JSON = settings.GOOGLE_CREDENTIALS_JSON
|
145 |
+
# 同时更新环境变量,确保其他模块能够访问到
|
146 |
+
os.environ["GOOGLE_CREDENTIALS_JSON"] = settings.GOOGLE_CREDENTIALS_JSON
|
147 |
+
log('info', "已更新app_config和环境变量中的GOOGLE_CREDENTIALS_JSON")
|
148 |
+
|
149 |
+
# 更新app_config中的VERTEX_EXPRESS_API_KEY_VAL
|
150 |
+
if hasattr(settings, 'VERTEX_EXPRESS_API_KEY') and settings.VERTEX_EXPRESS_API_KEY:
|
151 |
+
app_config.VERTEX_EXPRESS_API_KEY_VAL = [key.strip() for key in settings.VERTEX_EXPRESS_API_KEY.split(',') if key.strip()]
|
152 |
+
# 同时更新环境变量
|
153 |
+
os.environ["VERTEX_EXPRESS_API_KEY"] = settings.VERTEX_EXPRESS_API_KEY
|
154 |
+
log('info', f"已更新app_config和环境变量中的VERTEX_EXPRESS_API_KEY_VAL,共{len(app_config.VERTEX_EXPRESS_API_KEY_VAL)}个有效密钥")
|
155 |
+
|
156 |
+
log('info', "配置更新完成,Vertex AI将在下次请求时重新初始化")
|
157 |
+
|
158 |
+
except Exception as e:
|
159 |
+
log('error', f"更新配置时出错: {str(e)}")
|
160 |
+
|
161 |
+
log('info', f"加载设置成功")
|
162 |
+
return True
|
163 |
+
except Exception as e:
|
164 |
+
log('error', f"加载设置时出错: {e}")
|
165 |
+
return False
|
app/config/safety.py
ADDED
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# 安全设置配置
|
2 |
+
|
3 |
+
# Gemini 1.0 安全设置
|
4 |
+
SAFETY_SETTINGS = [
|
5 |
+
{
|
6 |
+
"category": "HARM_CATEGORY_HARASSMENT",
|
7 |
+
"threshold": "BLOCK_NONE"
|
8 |
+
},
|
9 |
+
{
|
10 |
+
"category": "HARM_CATEGORY_HATE_SPEECH",
|
11 |
+
"threshold": "BLOCK_NONE"
|
12 |
+
},
|
13 |
+
{
|
14 |
+
"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT",
|
15 |
+
"threshold": "BLOCK_NONE"
|
16 |
+
},
|
17 |
+
{
|
18 |
+
"category": "HARM_CATEGORY_DANGEROUS_CONTENT",
|
19 |
+
"threshold": "BLOCK_NONE"
|
20 |
+
},
|
21 |
+
{
|
22 |
+
"category": 'HARM_CATEGORY_CIVIC_INTEGRITY',
|
23 |
+
"threshold": 'BLOCK_NONE'
|
24 |
+
}
|
25 |
+
]
|
26 |
+
|
27 |
+
# Gemini 2.0 安全设置
|
28 |
+
SAFETY_SETTINGS_G2 = [
|
29 |
+
{
|
30 |
+
"category": "HARM_CATEGORY_HARASSMENT",
|
31 |
+
"threshold": "OFF"
|
32 |
+
},
|
33 |
+
{
|
34 |
+
"category": "HARM_CATEGORY_HATE_SPEECH",
|
35 |
+
"threshold": "OFF"
|
36 |
+
},
|
37 |
+
{
|
38 |
+
"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT",
|
39 |
+
"threshold": "OFF"
|
40 |
+
},
|
41 |
+
{
|
42 |
+
"category": "HARM_CATEGORY_DANGEROUS_CONTENT",
|
43 |
+
"threshold": "OFF"
|
44 |
+
},
|
45 |
+
{
|
46 |
+
"category": 'HARM_CATEGORY_CIVIC_INTEGRITY',
|
47 |
+
"threshold": 'OFF'
|
48 |
+
}
|
49 |
+
]
|
app/config/settings.py
ADDED
@@ -0,0 +1,125 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import os
|
2 |
+
import pathlib
|
3 |
+
import logging
|
4 |
+
from datetime import datetime, timedelta
|
5 |
+
import asyncio
|
6 |
+
|
7 |
+
# ---------- 以下是基础配置信息 ----------
|
8 |
+
|
9 |
+
# 调用本项目时使用的密码
|
10 |
+
PASSWORD = os.environ.get("PASSWORD", "123").strip('"')
|
11 |
+
|
12 |
+
# 网页配置密码,设置后,在网页修改配置时使用 WEB_PASSWORD 而不是上面的 PASSWORD
|
13 |
+
WEB_PASSWORD = os.environ.get("WEB_PASSWORD", PASSWORD).strip('"')
|
14 |
+
|
15 |
+
# API密钥
|
16 |
+
GEMINI_API_KEYS = os.environ.get("GEMINI_API_KEYS", "")
|
17 |
+
|
18 |
+
# 假流式是否开启
|
19 |
+
FAKE_STREAMING = os.environ.get("FAKE_STREAMING", "true").lower() in ["true", "1", "yes"]
|
20 |
+
|
21 |
+
# 配置持久化存储目录
|
22 |
+
STORAGE_DIR = os.environ.get("STORAGE_DIR", "/hajimi/settings/")
|
23 |
+
ENABLE_STORAGE = os.environ.get("ENABLE_STORAGE", "false").lower() in ["true", "1", "yes"]
|
24 |
+
|
25 |
+
# 并发请求配置
|
26 |
+
CONCURRENT_REQUESTS = int(os.environ.get("CONCURRENT_REQUESTS", "1")) # 默认并发请求数
|
27 |
+
INCREASE_CONCURRENT_ON_FAILURE = int(os.environ.get("INCREASE_CONCURRENT_ON_FAILURE", "0")) # 失败时增加的并发数
|
28 |
+
MAX_CONCURRENT_REQUESTS = int(os.environ.get("MAX_CONCURRENT_REQUESTS", "3")) # 最大并发请求数
|
29 |
+
|
30 |
+
# 缓存配置
|
31 |
+
CACHE_EXPIRY_TIME = int(os.environ.get("CACHE_EXPIRY_TIME", "21600")) # 默认缓存 6 小时 (21600 秒)
|
32 |
+
MAX_CACHE_ENTRIES = int(os.environ.get("MAX_CACHE_ENTRIES", "500")) # 默认最多缓存500条响应
|
33 |
+
CALCULATE_CACHE_ENTRIES = int(os.environ.get("CALCULATE_CACHE_ENTRIES", "6")) # 默认取最后 6 条消息算缓存键
|
34 |
+
PRECISE_CACHE = os.environ.get("PRECISE_CACHE", "false").lower() in ["true", "1", "yes"] #是否取所有消息来算缓存键
|
35 |
+
|
36 |
+
# 是否启用 Vertex AI
|
37 |
+
ENABLE_VERTEX = os.environ.get("ENABLE_VERTEX", "false").lower() in ["true", "1", "yes"]
|
38 |
+
GOOGLE_CREDENTIALS_JSON = os.environ.get("GOOGLE_CREDENTIALS_JSON", "")
|
39 |
+
|
40 |
+
# 是否启用快速模式 Vertex
|
41 |
+
ENABLE_VERTEX_EXPRESS = os.environ.get("ENABLE_VERTEX_EXPRESS", "false").lower() in ["true", "1", "yes"]
|
42 |
+
VERTEX_EXPRESS_API_KEY = os.environ.get("VERTEX_EXPRESS_API_KEY", "")
|
43 |
+
|
44 |
+
# 联网搜索配置
|
45 |
+
search={
|
46 |
+
"search_mode":os.environ.get("SEARCH_MODE", "false").lower() in ["true", "1", "yes"],
|
47 |
+
"search_prompt":os.environ.get("SEARCH_PROMPT", "(使用搜索工具联网搜索,需要在content中结合搜索内容)").strip('"')
|
48 |
+
}
|
49 |
+
|
50 |
+
#随机字符串
|
51 |
+
RANDOM_STRING = os.environ.get("RANDOM_STRING", "true").lower() in ["true", "1", "yes"]
|
52 |
+
RANDOM_STRING_LENGTH = int(os.environ.get("RANDOM_STRING_LENGTH", "5"))
|
53 |
+
|
54 |
+
# 空响应重试次数限制
|
55 |
+
MAX_EMPTY_RESPONSES = int(os.environ.get("MAX_EMPTY_RESPONSES", "5")) # 默认最多允许5次空响应
|
56 |
+
|
57 |
+
# ---------- 以下是其他配置信息 ----------
|
58 |
+
|
59 |
+
# 访问限制
|
60 |
+
MAX_RETRY_NUM = int(os.environ.get("MAX_RETRY_NUM", "15")) # 请求时的最大总轮询 key 数
|
61 |
+
MAX_REQUESTS_PER_MINUTE = int(os.environ.get("MAX_REQUESTS_PER_MINUTE", "30"))
|
62 |
+
MAX_REQUESTS_PER_DAY_PER_IP = int(os.environ.get("MAX_REQUESTS_PER_DAY_PER_IP", "600"))
|
63 |
+
|
64 |
+
# API密钥使用限制
|
65 |
+
API_KEY_DAILY_LIMIT = int(os.environ.get("API_KEY_DAILY_LIMIT", "100"))# 默认每个API密钥每24小时可使用100次
|
66 |
+
|
67 |
+
# 模型屏蔽黑名单,格式应为逗号分隔的模型名称集合
|
68 |
+
BLOCKED_MODELS = { model.strip() for model in os.environ.get("BLOCKED_MODELS", "").split(",") if model.strip() }
|
69 |
+
|
70 |
+
#公益站模式
|
71 |
+
PUBLIC_MODE = os.environ.get("PUBLIC_MODE", "false").lower() in ["true", "1", "yes"]
|
72 |
+
#前端地址
|
73 |
+
DASHBOARD_URL = os.environ.get("DASHBOARD_URL", "")
|
74 |
+
|
75 |
+
# 模型屏蔽白名单
|
76 |
+
WHITELIST_MODELS = { x.strip() for x in os.environ.get("WHITELIST_MODELS", "").split(",") if x.strip() }
|
77 |
+
# 白名单User-Agent
|
78 |
+
WHITELIST_USER_AGENT = { x.strip().lower() for x in os.environ.get("WHITELIST_USER_AGENT", "").split(",") if x.strip() }
|
79 |
+
|
80 |
+
# 跨域配置
|
81 |
+
# 允许的源列表,逗号分隔,例如 "http://localhost:3000,https://example.com"
|
82 |
+
ALLOWED_ORIGINS_STR = os.environ.get("ALLOWED_ORIGINS", "")
|
83 |
+
ALLOWED_ORIGINS = [origin.strip() for origin in ALLOWED_ORIGINS_STR.split(",") if origin.strip()]
|
84 |
+
|
85 |
+
# ---------- 运行时全局信息,无需修改 ----------
|
86 |
+
|
87 |
+
# 基础目录设置
|
88 |
+
BASE_DIR = pathlib.Path(__file__).parent.parent
|
89 |
+
|
90 |
+
# 失效的API密钥
|
91 |
+
INVALID_API_KEYS = os.environ.get("INVALID_API_KEYS", "")
|
92 |
+
|
93 |
+
version={
|
94 |
+
"local_version":"0.0.0",
|
95 |
+
"remote_version":"0.0.0",
|
96 |
+
"has_update":False
|
97 |
+
}
|
98 |
+
|
99 |
+
# API调用统计
|
100 |
+
# 这个对象保留为空结构以保持向后兼容性
|
101 |
+
# 实际统计数据已迁移到 app/utils/stats.py 中的 ApiStatsManager 类
|
102 |
+
api_call_stats = {
|
103 |
+
'calls': [] # 兼容旧版代码结构
|
104 |
+
}
|
105 |
+
|
106 |
+
# 用于保护 api_call_stats 并发访问的锁
|
107 |
+
stats_lock = asyncio.Lock()
|
108 |
+
|
109 |
+
# 日志配置
|
110 |
+
logging.getLogger("uvicorn").disabled = True
|
111 |
+
logging.getLogger("uvicorn.access").disabled = True
|
112 |
+
|
113 |
+
|
114 |
+
# ---------- 以下配置信息已废弃 ----------
|
115 |
+
|
116 |
+
# 假流式请求的空内容返回间隔(秒)
|
117 |
+
FAKE_STREAMING_INTERVAL = float(os.environ.get("FAKE_STREAMING_INTERVAL", "1"))
|
118 |
+
# 假流式响应的每个块大小
|
119 |
+
FAKE_STREAMING_CHUNK_SIZE = int(os.environ.get("FAKE_STREAMING_CHUNK_SIZE", "10"))
|
120 |
+
# ���流式响应的每个块之间的延迟(秒)
|
121 |
+
FAKE_STREAMING_DELAY_PER_CHUNK = float(os.environ.get("FAKE_STREAMING_DELAY_PER_CHUNK", "0.1"))
|
122 |
+
|
123 |
+
# 非流式请求TCP保活配置
|
124 |
+
NONSTREAM_KEEPALIVE_ENABLED = os.environ.get("NONSTREAM_KEEPALIVE_ENABLED", "true").lower() in ["true", "1", "yes"]
|
125 |
+
NONSTREAM_KEEPALIVE_INTERVAL = float(os.environ.get("NONSTREAM_KEEPALIVE_INTERVAL", "5.0"))
|
app/main.py
ADDED
@@ -0,0 +1,260 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from fastapi import FastAPI, Request
|
2 |
+
from fastapi.responses import JSONResponse, HTMLResponse
|
3 |
+
from fastapi.staticfiles import StaticFiles
|
4 |
+
from fastapi.templating import Jinja2Templates
|
5 |
+
from fastapi.middleware.cors import CORSMiddleware
|
6 |
+
from app.models.schemas import ErrorResponse
|
7 |
+
from app.services import GeminiClient
|
8 |
+
from app.utils import (
|
9 |
+
APIKeyManager,
|
10 |
+
test_api_key,
|
11 |
+
ResponseCacheManager,
|
12 |
+
ActiveRequestsManager,
|
13 |
+
check_version,
|
14 |
+
schedule_cache_cleanup,
|
15 |
+
handle_exception,
|
16 |
+
log
|
17 |
+
)
|
18 |
+
from app.config.persistence import save_settings, load_settings
|
19 |
+
from app.api import router, init_router, dashboard_router, init_dashboard_router
|
20 |
+
from app.vertex.vertex_ai_init import init_vertex_ai
|
21 |
+
from app.vertex.credentials_manager import CredentialManager
|
22 |
+
import app.config.settings as settings
|
23 |
+
from app.config.safety import SAFETY_SETTINGS, SAFETY_SETTINGS_G2
|
24 |
+
import asyncio
|
25 |
+
import sys
|
26 |
+
import pathlib
|
27 |
+
import os
|
28 |
+
# 设置模板目录
|
29 |
+
BASE_DIR = pathlib.Path(__file__).parent
|
30 |
+
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
|
31 |
+
|
32 |
+
app = FastAPI(limit="50M")
|
33 |
+
|
34 |
+
# --------------- CORS 中间件 ---------------
|
35 |
+
# 如果 ALLOWED_ORIGINS 为空列表,则不允许任何跨域请求
|
36 |
+
if settings.ALLOWED_ORIGINS:
|
37 |
+
app.add_middleware(
|
38 |
+
CORSMiddleware,
|
39 |
+
allow_origins=settings.ALLOWED_ORIGINS,
|
40 |
+
allow_credentials=True,
|
41 |
+
allow_methods=["*"],
|
42 |
+
allow_headers=["*"],
|
43 |
+
)
|
44 |
+
|
45 |
+
# --------------- 全局实例 ---------------
|
46 |
+
load_settings()
|
47 |
+
# 初始化API密钥管理器
|
48 |
+
key_manager = APIKeyManager()
|
49 |
+
|
50 |
+
# 创建全局缓存字典,将作为缓存管理器的内部存储
|
51 |
+
response_cache = {}
|
52 |
+
|
53 |
+
# 初始化缓存管理器,使用全局字典作为存储
|
54 |
+
response_cache_manager = ResponseCacheManager(
|
55 |
+
expiry_time=settings.CACHE_EXPIRY_TIME,
|
56 |
+
max_entries=settings.MAX_CACHE_ENTRIES,
|
57 |
+
cache_dict=response_cache
|
58 |
+
)
|
59 |
+
|
60 |
+
# 活跃请求池 - 将作为活跃请求管理器的内部存储
|
61 |
+
active_requests_pool = {}
|
62 |
+
|
63 |
+
# 初始化活跃请求管理器
|
64 |
+
active_requests_manager = ActiveRequestsManager(requests_pool=active_requests_pool)
|
65 |
+
|
66 |
+
SKIP_CHECK_API_KEY = os.environ.get("SKIP_CHECK_API_KEY", "").lower() == "true"
|
67 |
+
|
68 |
+
# --------------- 工具函数 ---------------
|
69 |
+
# @app.middleware("http")
|
70 |
+
# async def log_requests(request: Request, call_next):
|
71 |
+
# """
|
72 |
+
# DEBUG用,接收并打印请求内容
|
73 |
+
# """
|
74 |
+
# log('info', f"接收到请求: {request.method} {request.url}")
|
75 |
+
# try:
|
76 |
+
# body = await request.json()
|
77 |
+
# log('info', f"请求体: {body}")
|
78 |
+
# except Exception:
|
79 |
+
# log('info', "请求体不是 JSON 格式或者为空")
|
80 |
+
|
81 |
+
# response = await call_next(request)
|
82 |
+
# return response
|
83 |
+
|
84 |
+
async def check_remaining_keys_async(keys_to_check: list, initial_invalid_keys: list):
|
85 |
+
"""
|
86 |
+
在后台异步检查剩余的 API 密钥。
|
87 |
+
"""
|
88 |
+
local_invalid_keys = []
|
89 |
+
found_valid_keys =False
|
90 |
+
|
91 |
+
log('info', f" 开始在后台检查剩余 API Key 是否有效")
|
92 |
+
for key in keys_to_check:
|
93 |
+
is_valid = await test_api_key(key)
|
94 |
+
if is_valid:
|
95 |
+
if key not in key_manager.api_keys: # 避免重复添加
|
96 |
+
key_manager.api_keys.append(key)
|
97 |
+
found_valid_keys = True
|
98 |
+
# log('info', f"API Key {key[:8]}... 有效")
|
99 |
+
else:
|
100 |
+
local_invalid_keys.append(key)
|
101 |
+
log('warning', f" API Key {key[:8]}... 无效")
|
102 |
+
|
103 |
+
await asyncio.sleep(0.05) # 短暂休眠,避免请求过于密集
|
104 |
+
|
105 |
+
if found_valid_keys:
|
106 |
+
key_manager._reset_key_stack() # 如果找到新的有效key,重置栈
|
107 |
+
|
108 |
+
# 合并所有无效密钥 (初始无效 + 后台检查出的无效)
|
109 |
+
combined_invalid_keys = list(set(initial_invalid_keys + local_invalid_keys))
|
110 |
+
|
111 |
+
# 获取当前设置中的无效密钥
|
112 |
+
current_invalid_keys_str = settings.INVALID_API_KEYS or ""
|
113 |
+
current_invalid_keys_set = set(k.strip() for k in current_invalid_keys_str.split(',') if k.strip())
|
114 |
+
|
115 |
+
# 更新无效密钥集合
|
116 |
+
new_invalid_keys_set = current_invalid_keys_set.union(set(combined_invalid_keys))
|
117 |
+
|
118 |
+
# 只有当无效密钥列表发生变化时才保存
|
119 |
+
if new_invalid_keys_set != current_invalid_keys_set:
|
120 |
+
settings.INVALID_API_KEYS = ','.join(sorted(list(new_invalid_keys_set)))
|
121 |
+
save_settings()
|
122 |
+
|
123 |
+
log('info', f"密钥检查任务完成。当前总可用密钥数量: {len(key_manager.api_keys)}")
|
124 |
+
|
125 |
+
# 设置全局异常处理
|
126 |
+
sys.excepthook = handle_exception
|
127 |
+
|
128 |
+
# --------------- 事件处理 ---------------
|
129 |
+
|
130 |
+
@app.on_event("startup")
|
131 |
+
async def startup_event():
|
132 |
+
|
133 |
+
# 首先加载持久化设置,确保所有配置都是最新的
|
134 |
+
load_settings()
|
135 |
+
|
136 |
+
|
137 |
+
# 重新加载vertex配置,确保获取到最新的持久化设置
|
138 |
+
import app.vertex.config as vertex_config
|
139 |
+
vertex_config.reload_config()
|
140 |
+
|
141 |
+
|
142 |
+
# 初始化CredentialManager
|
143 |
+
credential_manager_instance = CredentialManager()
|
144 |
+
# 添加到应用程序状态
|
145 |
+
app.state.credential_manager = credential_manager_instance
|
146 |
+
|
147 |
+
# 初始化Vertex AI服务
|
148 |
+
await init_vertex_ai(credential_manager=credential_manager_instance)
|
149 |
+
schedule_cache_cleanup(response_cache_manager, active_requests_manager)
|
150 |
+
# 检查版本
|
151 |
+
await check_version()
|
152 |
+
|
153 |
+
# 密钥检查
|
154 |
+
initial_keys = key_manager.api_keys.copy()
|
155 |
+
key_manager.api_keys = [] # 清空,等待检查结果
|
156 |
+
first_valid_key = None
|
157 |
+
initial_invalid_keys = []
|
158 |
+
keys_to_check_later = []
|
159 |
+
|
160 |
+
# 阻塞式查找第一个有效密钥
|
161 |
+
for index, key in enumerate(initial_keys):
|
162 |
+
is_valid = await test_api_key(key)
|
163 |
+
if is_valid:
|
164 |
+
log('info', f"找到第一个有效密钥: {key[:8]}...")
|
165 |
+
first_valid_key = key
|
166 |
+
key_manager.api_keys.append(key) # 添加到管理器
|
167 |
+
key_manager._reset_key_stack()
|
168 |
+
# 将剩余的key放入后台检查列表
|
169 |
+
keys_to_check_later = initial_keys[index + 1:]
|
170 |
+
break # 找到即停止
|
171 |
+
else:
|
172 |
+
log('warning', f"密钥 {key[:8]}... 无效")
|
173 |
+
initial_invalid_keys.append(key)
|
174 |
+
|
175 |
+
if not first_valid_key:
|
176 |
+
log('error', "启动时未能找到任何有效 API 密钥!")
|
177 |
+
keys_to_check_later = [] # 没有有效key,无需后台检查
|
178 |
+
else:
|
179 |
+
# 使用第一个有效密钥加载模型
|
180 |
+
try:
|
181 |
+
all_models = await GeminiClient.list_available_models(first_valid_key)
|
182 |
+
GeminiClient.AVAILABLE_MODELS = [model.replace("models/", "") for model in all_models]
|
183 |
+
log('info', f"使用密钥 {first_valid_key[:8]}... 加载可用模型成功")
|
184 |
+
except Exception as e:
|
185 |
+
log('warning', f"使用密钥 {first_valid_key[:8]}... 加载可用模型失败",extra={'error_message': str(e)})
|
186 |
+
|
187 |
+
if not SKIP_CHECK_API_KEY:
|
188 |
+
# 创建后台任务检查剩余密钥
|
189 |
+
if keys_to_check_later:
|
190 |
+
asyncio.create_task(check_remaining_keys_async(keys_to_check_later, initial_invalid_keys))
|
191 |
+
else:
|
192 |
+
# 如果没有需要后台检查的key,也要处理初始无效key
|
193 |
+
current_invalid_keys_str = settings.INVALID_API_KEYS or ""
|
194 |
+
current_invalid_keys_set = set(k.strip() for k in current_invalid_keys_str.split(',') if k.strip())
|
195 |
+
new_invalid_keys_set = current_invalid_keys_set.union(set(initial_invalid_keys))
|
196 |
+
if new_invalid_keys_set != current_invalid_keys_set:
|
197 |
+
settings.INVALID_API_KEYS = ','.join(sorted(list(new_invalid_keys_set)))
|
198 |
+
save_settings()
|
199 |
+
log('info', f"更新初始无效密钥列表完成,总无效密钥数: {len(new_invalid_keys_set)}")
|
200 |
+
|
201 |
+
else: # 跳过检查
|
202 |
+
log('info',"跳过 API 密钥检查")
|
203 |
+
key_manager.api_keys.extend(keys_to_check_later)
|
204 |
+
key_manager._reset_key_stack()
|
205 |
+
|
206 |
+
# 初始化路由器
|
207 |
+
init_router(
|
208 |
+
key_manager,
|
209 |
+
response_cache_manager,
|
210 |
+
active_requests_manager,
|
211 |
+
SAFETY_SETTINGS,
|
212 |
+
SAFETY_SETTINGS_G2,
|
213 |
+
first_valid_key,
|
214 |
+
settings.FAKE_STREAMING,
|
215 |
+
settings.FAKE_STREAMING_INTERVAL,
|
216 |
+
settings.PASSWORD,
|
217 |
+
settings.MAX_REQUESTS_PER_MINUTE,
|
218 |
+
settings.MAX_REQUESTS_PER_DAY_PER_IP
|
219 |
+
)
|
220 |
+
|
221 |
+
# 初始化仪表盘路由器
|
222 |
+
init_dashboard_router(
|
223 |
+
key_manager,
|
224 |
+
response_cache_manager,
|
225 |
+
active_requests_manager,
|
226 |
+
credential_manager_instance
|
227 |
+
)
|
228 |
+
|
229 |
+
# --------------- 异常处理 ---------------
|
230 |
+
|
231 |
+
@app.exception_handler(Exception)
|
232 |
+
async def global_exception_handler(request: Request, exc: Exception):
|
233 |
+
from app.utils import translate_error
|
234 |
+
error_message = translate_error(str(exc))
|
235 |
+
extra_log_unhandled_exception = {'status_code': 500, 'error_message': error_message}
|
236 |
+
log('error', f"Unhandled exception: {error_message}", extra=extra_log_unhandled_exception)
|
237 |
+
return JSONResponse(status_code=500, content=ErrorResponse(message=str(exc), type="internal_error").dict())
|
238 |
+
|
239 |
+
# --------------- 路由 ---------------
|
240 |
+
|
241 |
+
app.include_router(router)
|
242 |
+
app.include_router(dashboard_router)
|
243 |
+
|
244 |
+
# 挂载静态文件目录
|
245 |
+
app.mount("/assets", StaticFiles(directory="app/templates/assets"), name="assets")
|
246 |
+
|
247 |
+
# 设置根路由路径
|
248 |
+
dashboard_path = f"/{settings.DASHBOARD_URL}" if settings.DASHBOARD_URL else "/"
|
249 |
+
|
250 |
+
@app.get(dashboard_path, response_class=HTMLResponse)
|
251 |
+
async def root(request: Request):
|
252 |
+
"""
|
253 |
+
根路由 - 返回静态 HTML 文件
|
254 |
+
"""
|
255 |
+
base_url = str(request.base_url).replace("http", "https")
|
256 |
+
api_url = f"{base_url}v1" if base_url.endswith("/") else f"{base_url}/v1"
|
257 |
+
# 直接返回 index.html 文件
|
258 |
+
return templates.TemplateResponse(
|
259 |
+
"index.html", {"request": request, "api_url": api_url}
|
260 |
+
)
|
app/models/schemas.py
ADDED
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from typing import List, Dict, Optional, Union, Literal, Any
|
2 |
+
from pydantic import BaseModel, Field
|
3 |
+
|
4 |
+
# openAI 请求
|
5 |
+
class ChatCompletionRequest(BaseModel):
|
6 |
+
model: str
|
7 |
+
messages: List[Dict[str, Any]]
|
8 |
+
temperature: float = 0.7
|
9 |
+
top_p: Optional[float] = None
|
10 |
+
top_k: Optional[float] = None
|
11 |
+
n: int = 1
|
12 |
+
stream: bool = False
|
13 |
+
stop: Optional[Union[str, List[str]]] = None
|
14 |
+
max_tokens: Optional[int] = None
|
15 |
+
presence_penalty: Optional[float] = 0.0
|
16 |
+
frequency_penalty: Optional[float] = 0.0
|
17 |
+
seed: Optional[int] = None
|
18 |
+
logprobs: Optional[int] = None
|
19 |
+
response_logprobs: Optional[bool] = None
|
20 |
+
thinking_budget: Optional[int] = None
|
21 |
+
reasoning_effort : Optional[str] = None
|
22 |
+
# 函数调用
|
23 |
+
tools: Optional[List[Dict[str, Any]]] = None
|
24 |
+
tool_choice: Optional[Union[Literal["none", "auto"], Dict[str, Any]]] = "auto"
|
25 |
+
|
26 |
+
# gemini 请求
|
27 |
+
class ChatRequestGemini(BaseModel):
|
28 |
+
contents: List[Dict[str, Any]]
|
29 |
+
system_instruction: Optional[Dict[str, Any]]= None
|
30 |
+
systemInstruction: Optional[Dict[str, Any]]= None
|
31 |
+
safetySettings: Optional[List[Dict[str, Any]]] = None
|
32 |
+
generationConfig: Optional[Dict[str, Any]] = None
|
33 |
+
tools: Optional[List[Dict[str, Any]]] = None
|
34 |
+
|
35 |
+
# AI模型请求包装
|
36 |
+
class AIRequest(BaseModel):
|
37 |
+
payload: Optional[ChatRequestGemini] = None
|
38 |
+
model: Optional[str] = None
|
39 |
+
stream: bool = False
|
40 |
+
format_type: Optional[str] = "gemini"
|
41 |
+
|
42 |
+
class Usage(BaseModel):
|
43 |
+
prompt_tokens: int = 0
|
44 |
+
completion_tokens: int = 0
|
45 |
+
total_tokens: int = 0
|
46 |
+
|
47 |
+
class ChatCompletionResponse(BaseModel):
|
48 |
+
id: str
|
49 |
+
object: Literal["chat.completion"]
|
50 |
+
created: int
|
51 |
+
model: str
|
52 |
+
choices: List[Any]
|
53 |
+
usage: Usage = Field(default_factory=Usage)
|
54 |
+
|
55 |
+
class ErrorResponse(BaseModel):
|
56 |
+
message: str
|
57 |
+
type: str
|
58 |
+
param: Optional[str] = None
|
59 |
+
code: Optional[str] = None
|
60 |
+
|
61 |
+
class ModelList(BaseModel):
|
62 |
+
object: str = "list"
|
63 |
+
data: List[Dict[str, Any]]
|
64 |
+
|
65 |
+
class ChatResponseGemini(BaseModel):
|
66 |
+
candidates: Optional[List[Any]] = None
|
67 |
+
promptFeedback: Optional[Any] = None
|
68 |
+
usageMetadata: Optional[Dict[str, int]] = None
|
app/services/OpenAI.py
ADDED
@@ -0,0 +1,107 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import json
|
2 |
+
import os
|
3 |
+
import asyncio
|
4 |
+
from app.models.schemas import ChatCompletionRequest
|
5 |
+
from dataclasses import dataclass
|
6 |
+
from typing import Optional, Dict, Any, List
|
7 |
+
import httpx
|
8 |
+
import logging
|
9 |
+
import secrets
|
10 |
+
import string
|
11 |
+
from app.utils import format_log_message
|
12 |
+
import app.config.settings as settings
|
13 |
+
|
14 |
+
from app.utils.logging import log
|
15 |
+
|
16 |
+
def generate_secure_random_string(length):
|
17 |
+
all_characters = string.ascii_letters + string.digits
|
18 |
+
secure_random_string = ''.join(secrets.choice(all_characters) for _ in range(length))
|
19 |
+
return secure_random_string
|
20 |
+
|
21 |
+
@dataclass
|
22 |
+
class GeneratedText:
|
23 |
+
text: str
|
24 |
+
finish_reason: Optional[str] = None
|
25 |
+
|
26 |
+
class OpenAIClient:
|
27 |
+
|
28 |
+
AVAILABLE_MODELS = []
|
29 |
+
EXTRA_MODELS = os.environ.get("EXTRA_MODELS", "").split(",")
|
30 |
+
|
31 |
+
def __init__(self, api_key: str):
|
32 |
+
self.api_key = api_key
|
33 |
+
|
34 |
+
def filter_data_by_whitelist(data, allowed_keys):
|
35 |
+
"""
|
36 |
+
根据白名单过滤字典。
|
37 |
+
Args:
|
38 |
+
data (dict): 原始的 Python 字典 (代表 JSON 对象)。
|
39 |
+
allowed_keys (list or set): 包含允许保留的键名的列表或集合。
|
40 |
+
使用集合 (set) 进行查找通常更快。
|
41 |
+
Returns:
|
42 |
+
dict: 只包含白名单中键的新字典。
|
43 |
+
"""
|
44 |
+
# 使用集合(set)可以提高查找效率,特别是当白名单很大时
|
45 |
+
allowed_keys_set = set(allowed_keys)
|
46 |
+
# 使用字典推导式创建过滤后的新字典
|
47 |
+
filtered_data = {key: value for key, value in data.items() if key in allowed_keys_set}
|
48 |
+
return filtered_data
|
49 |
+
|
50 |
+
# 真流式处理
|
51 |
+
async def stream_chat(self, request: ChatCompletionRequest):
|
52 |
+
whitelist = ["model", "messages", "temperature", "max_tokens","stream","tools","reasoning_effort","top_k","presence_penalty"]
|
53 |
+
|
54 |
+
data = self.filter_data_by_whitelist(request, whitelist)
|
55 |
+
|
56 |
+
|
57 |
+
if settings.search["search_mode"] and data.model.endswith("-search"):
|
58 |
+
log('INFO', "开启联网搜索模式", extra={'key': self.api_key[:8], 'model':request.model})
|
59 |
+
data.setdefault("tools", []).append({"google_search": {}})
|
60 |
+
|
61 |
+
data.model = data.model.removesuffix("-search")
|
62 |
+
|
63 |
+
# 真流式请求处理逻辑
|
64 |
+
extra_log = {'key': self.api_key[:8], 'request_type': 'stream', 'model': request.model}
|
65 |
+
log('INFO', "流式请求开始", extra=extra_log)
|
66 |
+
|
67 |
+
|
68 |
+
url = f"https://generativelanguage.googleapis.com/v1beta/openai/chat/completions"
|
69 |
+
headers = {
|
70 |
+
"Content-Type": "application/json",
|
71 |
+
"Authorization": f"Bearer {self.api_key}"
|
72 |
+
}
|
73 |
+
|
74 |
+
async with httpx.AsyncClient() as client:
|
75 |
+
async with client.stream("POST", url, headers=headers, json=data, timeout=600) as response:
|
76 |
+
buffer = b"" # 用于累积可能不完整的 JSON 数据
|
77 |
+
try:
|
78 |
+
async for line in response.aiter_lines():
|
79 |
+
if not line.strip(): # 跳过空行 (SSE 消息分隔符)
|
80 |
+
continue
|
81 |
+
if line.startswith("data: "):
|
82 |
+
line = line[len("data: "):].strip() # 去除 "data: " 前缀
|
83 |
+
|
84 |
+
# 检查是否是结束标志,如果是,结束循环
|
85 |
+
if line == "[DONE]":
|
86 |
+
break
|
87 |
+
|
88 |
+
buffer += line.encode('utf-8')
|
89 |
+
try:
|
90 |
+
# 尝试解析整个缓冲区
|
91 |
+
data = json.loads(buffer.decode('utf-8'))
|
92 |
+
# 解析成功,清空缓冲区
|
93 |
+
buffer = b""
|
94 |
+
|
95 |
+
yield data
|
96 |
+
|
97 |
+
except json.JSONDecodeError:
|
98 |
+
# JSON 不完整,继续累积到 buffer
|
99 |
+
continue
|
100 |
+
except Exception as e:
|
101 |
+
log('ERROR', f"流式处理期间发生错误",
|
102 |
+
extra={'key': self.api_key[:8], 'request_type': 'stream', 'model': request.model})
|
103 |
+
raise e
|
104 |
+
except Exception as e:
|
105 |
+
raise e
|
106 |
+
finally:
|
107 |
+
log('info', "流式请求结束")
|
app/services/__init__.py
ADDED
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from app.services.gemini import GeminiClient, GeminiResponseWrapper, GeneratedText
|
2 |
+
from app.services.OpenAI import OpenAIClient
|
3 |
+
|
4 |
+
__all__ = [
|
5 |
+
'GeminiClient',
|
6 |
+
'OpenAIClient',
|
7 |
+
'GeminiResponseWrapper',
|
8 |
+
'GeneratedText'
|
9 |
+
]
|
app/services/gemini.py
ADDED
@@ -0,0 +1,472 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import json
|
2 |
+
import os
|
3 |
+
import httpx
|
4 |
+
from app.models.schemas import ChatCompletionRequest
|
5 |
+
from dataclasses import dataclass
|
6 |
+
from typing import Optional, Dict, Any, List
|
7 |
+
import httpx
|
8 |
+
import secrets
|
9 |
+
import string
|
10 |
+
import app.config.settings as settings
|
11 |
+
|
12 |
+
from app.utils.logging import log
|
13 |
+
|
14 |
+
def generate_secure_random_string(length):
|
15 |
+
all_characters = string.ascii_letters + string.digits
|
16 |
+
secure_random_string = ''.join(secrets.choice(all_characters) for _ in range(length))
|
17 |
+
return secure_random_string
|
18 |
+
|
19 |
+
@dataclass
|
20 |
+
class GeneratedText:
|
21 |
+
text: str
|
22 |
+
finish_reason: Optional[str] = None
|
23 |
+
|
24 |
+
|
25 |
+
class GeminiResponseWrapper:
|
26 |
+
def __init__(self, data: Dict[Any, Any]):
|
27 |
+
self._data = data
|
28 |
+
self._text = self._extract_text()
|
29 |
+
self._finish_reason = self._extract_finish_reason()
|
30 |
+
self._prompt_token_count = self._extract_prompt_token_count()
|
31 |
+
self._candidates_token_count = self._extract_candidates_token_count()
|
32 |
+
self._total_token_count = self._extract_total_token_count()
|
33 |
+
self._thoughts = self._extract_thoughts()
|
34 |
+
self._function_call = self._extract_function_call()
|
35 |
+
self._json_dumps = json.dumps(self._data, indent=4, ensure_ascii=False)
|
36 |
+
self._model = "gemini"
|
37 |
+
|
38 |
+
def _extract_thoughts(self) -> Optional[str]:
|
39 |
+
try:
|
40 |
+
for part in self._data['candidates'][0]['content']['parts']:
|
41 |
+
if 'thought' in part:
|
42 |
+
return part['text']
|
43 |
+
return ""
|
44 |
+
except (KeyError, IndexError):
|
45 |
+
return ""
|
46 |
+
|
47 |
+
def _extract_text(self) -> str:
|
48 |
+
try:
|
49 |
+
text=""
|
50 |
+
for part in self._data['candidates'][0]['content']['parts']:
|
51 |
+
if 'thought' not in part and 'text' in part:
|
52 |
+
text += part['text']
|
53 |
+
return text
|
54 |
+
except (KeyError, IndexError):
|
55 |
+
return ""
|
56 |
+
|
57 |
+
def _extract_function_call(self) -> Optional[Dict[str, Any]]:
|
58 |
+
try:
|
59 |
+
parts = self._data.get('candidates', [{}])[0].get('content', {}).get('parts', [])
|
60 |
+
# 使用列表推导式查找所有包含 'functionCall' 的 part,并提取其值
|
61 |
+
function_calls = [
|
62 |
+
part['functionCall']
|
63 |
+
for part in parts
|
64 |
+
if isinstance(part, dict) and 'functionCall' in part
|
65 |
+
]
|
66 |
+
# 如果列表不为空,则返回列表;否则返回 None
|
67 |
+
return function_calls if function_calls else None
|
68 |
+
except (KeyError, IndexError, TypeError):
|
69 |
+
return None
|
70 |
+
|
71 |
+
def _extract_finish_reason(self) -> Optional[str]:
|
72 |
+
try:
|
73 |
+
return self._data['candidates'][0].get('finishReason')
|
74 |
+
except (KeyError, IndexError):
|
75 |
+
return None
|
76 |
+
|
77 |
+
def _extract_prompt_token_count(self) -> Optional[int]:
|
78 |
+
try:
|
79 |
+
return self._data['usageMetadata'].get('promptTokenCount')
|
80 |
+
except (KeyError):
|
81 |
+
return None
|
82 |
+
|
83 |
+
def _extract_candidates_token_count(self) -> Optional[int]:
|
84 |
+
try:
|
85 |
+
return self._data['usageMetadata'].get('candidatesTokenCount')
|
86 |
+
except (KeyError):
|
87 |
+
return None
|
88 |
+
|
89 |
+
def _extract_total_token_count(self) -> Optional[int]:
|
90 |
+
try:
|
91 |
+
return self._data['usageMetadata'].get('totalTokenCount')
|
92 |
+
except (KeyError):
|
93 |
+
return None
|
94 |
+
|
95 |
+
def set_model(self,model) -> Optional[str]:
|
96 |
+
self._model = model
|
97 |
+
|
98 |
+
@property
|
99 |
+
def data(self) -> Dict[Any, Any]:
|
100 |
+
return self._data
|
101 |
+
|
102 |
+
@property
|
103 |
+
def text(self) -> str:
|
104 |
+
return self._text
|
105 |
+
|
106 |
+
@property
|
107 |
+
def finish_reason(self) -> Optional[str]:
|
108 |
+
return self._finish_reason
|
109 |
+
|
110 |
+
@property
|
111 |
+
def prompt_token_count(self) -> Optional[int]:
|
112 |
+
return self._prompt_token_count
|
113 |
+
|
114 |
+
@property
|
115 |
+
def candidates_token_count(self) -> Optional[int]:
|
116 |
+
return self._candidates_token_count
|
117 |
+
|
118 |
+
@property
|
119 |
+
def total_token_count(self) -> Optional[int]:
|
120 |
+
return self._total_token_count
|
121 |
+
|
122 |
+
@property
|
123 |
+
def thoughts(self) -> Optional[str]:
|
124 |
+
return self._thoughts
|
125 |
+
|
126 |
+
@property
|
127 |
+
def json_dumps(self) -> str:
|
128 |
+
return self._json_dumps
|
129 |
+
|
130 |
+
@property
|
131 |
+
def model(self) -> str:
|
132 |
+
return self._model
|
133 |
+
|
134 |
+
@property
|
135 |
+
def function_call(self) -> Optional[Dict[str, Any]]:
|
136 |
+
return self._function_call
|
137 |
+
|
138 |
+
|
139 |
+
class GeminiClient:
|
140 |
+
|
141 |
+
AVAILABLE_MODELS = []
|
142 |
+
extra_models_str = os.environ.get("EXTRA_MODELS", "")
|
143 |
+
EXTRA_MODELS = [model.strip() for model in extra_models_str.split(",") if model.strip()]
|
144 |
+
|
145 |
+
def __init__(self, api_key: str):
|
146 |
+
self.api_key = api_key
|
147 |
+
|
148 |
+
# 请求参数处理
|
149 |
+
def _convert_request_data(self, request, contents, safety_settings, system_instruction):
|
150 |
+
|
151 |
+
model = request.model
|
152 |
+
format_type = getattr(request, 'format_type', None)
|
153 |
+
if format_type and (format_type == "gemini"):
|
154 |
+
api_version = "v1alpha" if "think" in request.model else "v1beta"
|
155 |
+
if request.payload:
|
156 |
+
# 将 Pydantic 模型转换为字典, 假设 Pydantic V2+
|
157 |
+
data = request.payload.model_dump(exclude_none=True)
|
158 |
+
# # 注入搜索提示
|
159 |
+
# if settings.search["search_mode"] and request.model and request.model.endswith("-search"):
|
160 |
+
# data.insert(len(data)-2,{'role': 'user', 'parts': [{'text':settings.search["search_prompt"]}]})
|
161 |
+
|
162 |
+
# # 注入随机字符串
|
163 |
+
# if settings.RANDOM_STRING:
|
164 |
+
# data.insert(1,{'role': 'user', 'parts': [{'text': generate_secure_random_string(settings.RANDOM_STRING_LENGTH)}]})
|
165 |
+
# data.insert(len(data)-1,{'role': 'user', 'parts': [{'text': generate_secure_random_string(settings.RANDOM_STRING_LENGTH)}]})
|
166 |
+
# log('INFO', "伪装消息成功")
|
167 |
+
|
168 |
+
else:
|
169 |
+
api_version, data = self._convert_openAI_request(request, contents, safety_settings, system_instruction)
|
170 |
+
|
171 |
+
# 联网模式
|
172 |
+
if settings.search["search_mode"] and request.model.endswith("-search"):
|
173 |
+
log('INFO', "开启联网搜索模式", extra={'key': self.api_key[:8], 'model':request.model})
|
174 |
+
|
175 |
+
data.setdefault("tools", []).append({"google_search": {}})
|
176 |
+
model= request.model.removesuffix("-search")
|
177 |
+
|
178 |
+
return api_version, model, data
|
179 |
+
|
180 |
+
|
181 |
+
def _convert_openAI_request(self, request: ChatCompletionRequest, contents, safety_settings, system_instruction):
|
182 |
+
|
183 |
+
config_params = {
|
184 |
+
"temperature": request.temperature,
|
185 |
+
"maxOutputTokens": request.max_tokens,
|
186 |
+
"topP": request.top_p,
|
187 |
+
"topK": request.top_k,
|
188 |
+
"stopSequences": request.stop if isinstance(request.stop, list) else [request.stop] if request.stop is not None else None,
|
189 |
+
"candidateCount": request.n,
|
190 |
+
}
|
191 |
+
if request.thinking_budget:
|
192 |
+
config_params["thinkingConfig"] = {
|
193 |
+
"thinkingBudget": request.thinking_budget
|
194 |
+
}
|
195 |
+
generationConfig = {k: v for k, v in config_params.items() if v is not None}
|
196 |
+
|
197 |
+
api_version = "v1alpha" if "think" in request.model else "v1beta"
|
198 |
+
|
199 |
+
data = {
|
200 |
+
"contents": contents,
|
201 |
+
"generationConfig": generationConfig,
|
202 |
+
"safetySettings": safety_settings,
|
203 |
+
}
|
204 |
+
|
205 |
+
# --- 函数调用处理 ---
|
206 |
+
# 1. 添加 tools (函数声明)
|
207 |
+
function_declarations = []
|
208 |
+
if request.tools:
|
209 |
+
# 显式提取 Gemini API 所需的字段,避免包含 'id' 等无效字段
|
210 |
+
function_declarations = []
|
211 |
+
for tool in request.tools:
|
212 |
+
if tool.get("type") == "function":
|
213 |
+
func_def = tool.get("function")
|
214 |
+
if func_def:
|
215 |
+
# 只包含 Gemini API 接受的字段
|
216 |
+
declaration = {
|
217 |
+
"name": func_def.get("name"),
|
218 |
+
"description": func_def.get("description"),
|
219 |
+
}
|
220 |
+
# 获取 parameters 并移除可能存在的 $schema 字段
|
221 |
+
parameters = func_def.get("parameters")
|
222 |
+
if isinstance(parameters, dict) and "$schema" in parameters:
|
223 |
+
parameters = parameters.copy()
|
224 |
+
del parameters["$schema"]
|
225 |
+
if parameters is not None:
|
226 |
+
declaration["parameters"] = parameters
|
227 |
+
|
228 |
+
# 移除值为 None 的键,以保持 payload 清洁
|
229 |
+
declaration = {k: v for k, v in declaration.items() if v is not None}
|
230 |
+
if declaration.get("name"): # 确保 name 存在
|
231 |
+
function_declarations.append(declaration)
|
232 |
+
|
233 |
+
if function_declarations:
|
234 |
+
data["tools"] = [{"function_declarations": function_declarations}]
|
235 |
+
|
236 |
+
# 2. 添加 tool_config (基于 tool_choice)
|
237 |
+
tool_config = None
|
238 |
+
if request.tool_choice:
|
239 |
+
choice = request.tool_choice
|
240 |
+
mode = None
|
241 |
+
allowed_functions = None
|
242 |
+
if isinstance(choice, str):
|
243 |
+
if choice == "none":
|
244 |
+
mode = "NONE"
|
245 |
+
elif choice == "auto":
|
246 |
+
mode = "AUTO"
|
247 |
+
elif isinstance(choice, dict) and choice.get("type") == "function":
|
248 |
+
func_name = choice.get("function", {}).get("name")
|
249 |
+
if func_name:
|
250 |
+
mode = "ANY" # 'ANY' 模式用于强制调用特定函数
|
251 |
+
allowed_functions = [func_name]
|
252 |
+
|
253 |
+
# 如果成功解析出有效的 mode,构建 tool_config
|
254 |
+
if mode:
|
255 |
+
config = {"mode": mode}
|
256 |
+
if allowed_functions:
|
257 |
+
config["allowed_function_names"] = allowed_functions
|
258 |
+
tool_config = {"function_calling_config": config}
|
259 |
+
|
260 |
+
# 3. 添加 tool_config 到 data
|
261 |
+
if tool_config:
|
262 |
+
data["tool_config"] = tool_config
|
263 |
+
|
264 |
+
if system_instruction:
|
265 |
+
data["system_instruction"] = system_instruction
|
266 |
+
|
267 |
+
return api_version, data
|
268 |
+
|
269 |
+
|
270 |
+
# 流式请求
|
271 |
+
async def stream_chat(self, request, contents, safety_settings, system_instruction):
|
272 |
+
# 真流式请求处理逻辑
|
273 |
+
extra_log = {'key': self.api_key[:8], 'request_type': 'stream', 'model': request.model}
|
274 |
+
log('INFO', "流式请求开始", extra=extra_log)
|
275 |
+
|
276 |
+
api_version, model, data = self._convert_request_data(request, contents, safety_settings, system_instruction)
|
277 |
+
|
278 |
+
|
279 |
+
url = f"https://generativelanguage.googleapis.com/{api_version}/models/{model}:streamGenerateContent?key={self.api_key}&alt=sse"
|
280 |
+
headers = {
|
281 |
+
"Content-Type": "application/json",
|
282 |
+
}
|
283 |
+
|
284 |
+
async with httpx.AsyncClient() as client:
|
285 |
+
async with client.stream("POST", url, headers=headers, json=data, timeout=600) as response:
|
286 |
+
response.raise_for_status()
|
287 |
+
buffer = b"" # 用于累积可能不完整的 JSON 数据
|
288 |
+
try:
|
289 |
+
async for line in response.aiter_lines():
|
290 |
+
if not line.strip(): # 跳过空行 (SSE 消息分隔符)
|
291 |
+
continue
|
292 |
+
if line.startswith("data: "):
|
293 |
+
line = line[len("data: "):].strip() # 去除 "data: " 前缀
|
294 |
+
|
295 |
+
# 检查是否是结束标志,如果是,结束循环
|
296 |
+
if line == "[DONE]":
|
297 |
+
break
|
298 |
+
|
299 |
+
buffer += line.encode('utf-8')
|
300 |
+
try:
|
301 |
+
# 尝试解析整个缓冲区
|
302 |
+
data = json.loads(buffer.decode('utf-8'))
|
303 |
+
# 解析成功,清空缓冲区
|
304 |
+
buffer = b""
|
305 |
+
yield GeminiResponseWrapper(data)
|
306 |
+
|
307 |
+
except json.JSONDecodeError:
|
308 |
+
# JSON 不完整,继续累积到 buffer
|
309 |
+
continue
|
310 |
+
except Exception as e:
|
311 |
+
log('ERROR', f"流式处理期间发生错误",
|
312 |
+
extra={'key': self.api_key[:8], 'request_type': 'stream', 'model': request.model})
|
313 |
+
raise e
|
314 |
+
except Exception as e:
|
315 |
+
raise e
|
316 |
+
finally:
|
317 |
+
log('info', "流式请求结束")
|
318 |
+
|
319 |
+
# 非流式处理
|
320 |
+
async def complete_chat(self, request, contents, safety_settings, system_instruction):
|
321 |
+
|
322 |
+
api_version, model, data = self._convert_request_data(request, contents, safety_settings, system_instruction)
|
323 |
+
|
324 |
+
url = f"https://generativelanguage.googleapis.com/{api_version}/models/{model}:generateContent?key={self.api_key}"
|
325 |
+
headers = {
|
326 |
+
"Content-Type": "application/json",
|
327 |
+
}
|
328 |
+
|
329 |
+
try:
|
330 |
+
async with httpx.AsyncClient() as client:
|
331 |
+
response = await client.post(url, headers=headers, json=data, timeout=600)
|
332 |
+
response.raise_for_status() # 检查 HTTP 错误状态
|
333 |
+
|
334 |
+
return GeminiResponseWrapper(response.json())
|
335 |
+
except Exception as e:
|
336 |
+
raise
|
337 |
+
|
338 |
+
# OpenAI 格式请求转换为 gemini 格式请求
|
339 |
+
def convert_messages(self, messages, use_system_prompt=False, model=None):
|
340 |
+
gemini_history = []
|
341 |
+
errors = []
|
342 |
+
|
343 |
+
system_instruction_text = ""
|
344 |
+
system_instruction_parts = [] # 用于收集系统指令文本
|
345 |
+
|
346 |
+
# 处理系统指令
|
347 |
+
if use_system_prompt:
|
348 |
+
# 遍历消息列表,查找开头的连续 system 消息
|
349 |
+
for i, message in enumerate(messages):
|
350 |
+
# 必须是 system 角色且内容是字符串
|
351 |
+
if message.get('role') == 'system' and isinstance(message.get('content'), str):
|
352 |
+
system_instruction_parts.append(message.get('content'))
|
353 |
+
else:
|
354 |
+
break # 遇到第一个非 system 或内容非字符串的消息就停止
|
355 |
+
|
356 |
+
# 将收集到的系统指令合并为一个字符串
|
357 |
+
system_instruction_text = "\n".join(system_instruction_parts)
|
358 |
+
system_instruction = {"parts": [{"text": system_instruction_text}]} if system_instruction_text else None
|
359 |
+
|
360 |
+
# 转换主要消息
|
361 |
+
|
362 |
+
for i, message in enumerate(messages):
|
363 |
+
role = message.get('role')
|
364 |
+
content = message.get('content')
|
365 |
+
if isinstance(content, str):
|
366 |
+
|
367 |
+
if role == 'tool':
|
368 |
+
role_to_use = 'function'
|
369 |
+
tool_call_id = message.get('tool_call_id')
|
370 |
+
|
371 |
+
prefix = "call_"
|
372 |
+
if tool_call_id.startswith(prefix):
|
373 |
+
# 假设 tool_call_id = f"call_{function_name}" (response.py中的处理)
|
374 |
+
function_name = tool_call_id[len(prefix):]
|
375 |
+
else:
|
376 |
+
continue
|
377 |
+
|
378 |
+
function_response_part = {
|
379 |
+
"functionResponse": {
|
380 |
+
"name": function_name,
|
381 |
+
"response": {"content": content}
|
382 |
+
}
|
383 |
+
}
|
384 |
+
|
385 |
+
gemini_history.append({"role": role_to_use, "parts": [function_response_part]})
|
386 |
+
|
387 |
+
continue
|
388 |
+
elif role in ['user', 'system']:
|
389 |
+
role_to_use = 'user'
|
390 |
+
elif role == 'assistant':
|
391 |
+
role_to_use = 'model'
|
392 |
+
|
393 |
+
else:
|
394 |
+
errors.append(f"Invalid role: {role}")
|
395 |
+
continue
|
396 |
+
|
397 |
+
# Gemini 的一个重要规则:连续的同角色消息需要合并
|
398 |
+
# 如果 gemini_history 已有内容,并且最后一条消息的角色和当前要添加的角色相同
|
399 |
+
if gemini_history and gemini_history[-1]['role'] == role_to_use:
|
400 |
+
gemini_history[-1]['parts'].append({"text": content})
|
401 |
+
else:
|
402 |
+
gemini_history.append({"role": role_to_use, "parts": [{"text": content}]})
|
403 |
+
elif isinstance(content, list):
|
404 |
+
parts = []
|
405 |
+
for item in content:
|
406 |
+
if item.get('type') == 'text':
|
407 |
+
parts.append({"text": item.get('text')})
|
408 |
+
elif item.get('type') == 'image_url':
|
409 |
+
image_data = item.get('image_url', {}).get('url', '')
|
410 |
+
if image_data.startswith('data:image/'):
|
411 |
+
try:
|
412 |
+
mime_type, base64_data = image_data.split(';')[0].split(':')[1], image_data.split(',')[1]
|
413 |
+
parts.append({
|
414 |
+
"inline_data": {
|
415 |
+
"mime_type": mime_type,
|
416 |
+
"data": base64_data
|
417 |
+
}
|
418 |
+
})
|
419 |
+
except (IndexError, ValueError):
|
420 |
+
errors.append(
|
421 |
+
f"Invalid data URI for image: {image_data}")
|
422 |
+
else:
|
423 |
+
errors.append(
|
424 |
+
f"Invalid image URL format for item: {item}")
|
425 |
+
|
426 |
+
if parts:
|
427 |
+
if role in ['user', 'system']:
|
428 |
+
role_to_use = 'user'
|
429 |
+
elif role == 'assistant':
|
430 |
+
role_to_use = 'model'
|
431 |
+
else:
|
432 |
+
errors.append(f"Invalid role: {role}")
|
433 |
+
continue
|
434 |
+
|
435 |
+
if gemini_history and gemini_history[-1]['role'] == role_to_use:
|
436 |
+
gemini_history[-1]['parts'].extend(parts)
|
437 |
+
else:
|
438 |
+
gemini_history.append(
|
439 |
+
{"role": role_to_use, "parts": parts})
|
440 |
+
if errors:
|
441 |
+
return errors
|
442 |
+
|
443 |
+
# --- 后处理 ---
|
444 |
+
|
445 |
+
# 注入搜索提示
|
446 |
+
if settings.search["search_mode"] and model and model.endswith("-search"):
|
447 |
+
gemini_history.insert(len(gemini_history)-2,{'role': 'user', 'parts': [{'text':settings.search["search_prompt"]}]})
|
448 |
+
|
449 |
+
# 注入随机字符串
|
450 |
+
if settings.RANDOM_STRING:
|
451 |
+
gemini_history.insert(1,{'role': 'user', 'parts': [{'text': generate_secure_random_string(settings.RANDOM_STRING_LENGTH)}]})
|
452 |
+
gemini_history.insert(len(gemini_history)-1,{'role': 'user', 'parts': [{'text': generate_secure_random_string(settings.RANDOM_STRING_LENGTH)}]})
|
453 |
+
log('INFO', "伪装消息成功")
|
454 |
+
|
455 |
+
return gemini_history, system_instruction
|
456 |
+
|
457 |
+
@staticmethod
|
458 |
+
async def list_available_models(api_key) -> list:
|
459 |
+
url = "https://generativelanguage.googleapis.com/v1beta/models?key={}".format(
|
460 |
+
api_key)
|
461 |
+
async with httpx.AsyncClient() as client:
|
462 |
+
response = await client.get(url)
|
463 |
+
response.raise_for_status()
|
464 |
+
data = response.json()
|
465 |
+
models = []
|
466 |
+
for model in data.get("models", []):
|
467 |
+
models.append(model["name"])
|
468 |
+
if model["name"].startswith("models/gemini-2") and settings.search["search_mode"]:
|
469 |
+
models.append(model["name"] + "-search")
|
470 |
+
models.extend(GeminiClient.EXTRA_MODELS)
|
471 |
+
|
472 |
+
return models
|
app/templates/__init__.py
ADDED
@@ -0,0 +1 @@
|
|
|
|
|
1 |
+
# Templates package initialization
|
app/templates/assets/0506c607efda914c9388132c9cbb0c53.js
ADDED
The diff for this file is too large to render.
See raw diff
|
|
app/templates/assets/9a4f356975f1a7b8b7bad9e93c1becba.css
ADDED
@@ -0,0 +1 @@
|
|
|
|
|
1 |
+
:root{--vt-c-white: #ffffff;--vt-c-white-soft: #f8f8f8;--vt-c-white-mute: #f2f2f2;--vt-c-black: #181818;--vt-c-black-soft: #222222;--vt-c-black-mute: #282828;--vt-c-indigo: #2c3e50;--vt-c-divider-light-1: rgba(60, 60, 60, .29);--vt-c-divider-light-2: rgba(60, 60, 60, .12);--vt-c-divider-dark-1: rgba(84, 84, 84, .65);--vt-c-divider-dark-2: rgba(84, 84, 84, .48);--vt-c-text-light-1: var(--vt-c-indigo);--vt-c-text-light-2: rgba(60, 60, 60, .66);--vt-c-text-dark-1: var(--vt-c-white);--vt-c-text-dark-2: rgba(235, 235, 235, .64)}:root{--color-background: var(--vt-c-white);--color-background-soft: var(--vt-c-white-soft);--color-background-mute: var(--vt-c-white-mute);--color-border: var(--vt-c-divider-light-2);--color-border-hover: var(--vt-c-divider-light-1);--color-heading: var(--vt-c-text-light-1);--color-text: var(--vt-c-text-light-1);--section-gap: 160px;--card-background: #ffffff;--card-border: #e0e0e0;--button-primary: #4f46e5;--button-primary-hover: #4338ca;--button-secondary: #f3f4f6;--button-secondary-hover: #e5e7eb;--button-secondary-text: #4b5563;--button-text: #ffffff;--stats-item-bg: #f8f9fa;--log-entry-bg: #f8f9fa;--log-entry-border: #e9ecef;--toggle-bg: #ccc;--toggle-active: #4f46e5;--gradient-primary: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%);--gradient-secondary: linear-gradient(135deg, #3b82f6 0%, #2dd4bf 100%);--gradient-success: linear-gradient(135deg, #10b981 0%, #059669 100%);--gradient-warning: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);--gradient-danger: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);--gradient-info: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, .05);--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, .1), 0 2px 4px -1px rgba(0, 0, 0, .06);--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, .1), 0 4px 6px -2px rgba(0, 0, 0, .05);--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, .1), 0 10px 10px -5px rgba(0, 0, 0, .04);--radius-sm: .25rem;--radius-md: .375rem;--radius-lg: .5rem;--radius-xl: .75rem;--radius-2xl: 1rem;--radius-full: 9999px;--transition-fast: .15s;--transition-normal: .3s;--transition-slow: .5s}.dark-mode{--color-background: var(--vt-c-black);--color-background-soft: var(--vt-c-black-soft);--color-background-mute: var(--vt-c-black-mute);--color-border: var(--vt-c-divider-dark-2);--color-border-hover: var(--vt-c-divider-dark-1);--color-heading: var(--vt-c-text-dark-1);--color-text: rgba(255, 255, 255, .85);--card-background: #1a1a1a;--card-border: #2a2a2a;--button-primary: #8b5cf6;--button-primary-hover: #7c3aed;--button-secondary: #2a2a2a;--button-secondary-hover: #3a3a3a;--button-secondary-text: #f1f5f9;--button-text: #ffffff;--stats-item-bg: #151515;--log-entry-bg: #151515;--log-entry-border: #2a2a2a;--toggle-bg: #3a3a3a;--toggle-active: #8b5cf6;--gradient-primary: linear-gradient(135deg, #7c3aed 0%, #5b21b6 100%);--gradient-secondary: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);--gradient-success: linear-gradient(135deg, #10b981 0%, #047857 100%);--gradient-warning: linear-gradient(135deg, #f59e0b 0%, #b45309 100%);--gradient-danger: linear-gradient(135deg, #ef4444 0%, #b91c1c 100%);--gradient-info: linear-gradient(135deg, #3b82f6 0%, #1e40af 100%);--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, .3);--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, .4), 0 2px 4px -1px rgba(0, 0, 0, .2);--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, .4), 0 4px 6px -2px rgba(0, 0, 0, .2);--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, .4), 0 10px 10px -5px rgba(0, 0, 0, .2)}@media (prefers-color-scheme: dark){:root:not(.dark-mode):not(.light-mode){--color-background: var(--vt-c-black);--color-background-soft: var(--vt-c-black-soft);--color-background-mute: var(--vt-c-black-mute);--color-border: var(--vt-c-divider-dark-2);--color-border-hover: var(--vt-c-divider-dark-1);--color-heading: var(--vt-c-text-dark-1);--color-text: rgba(255, 255, 255, .85);--card-background: #1a1a1a;--card-border: #2a2a2a;--button-primary: #8b5cf6;--button-primary-hover: #7c3aed;--button-secondary: #2a2a2a;--button-secondary-hover: #3a3a3a;--button-secondary-text: #f1f5f9;--button-text: #ffffff;--stats-item-bg: #151515;--log-entry-bg: #151515;--log-entry-border: #2a2a2a;--toggle-bg: #3a3a3a;--toggle-active: #8b5cf6;--gradient-primary: linear-gradient(135deg, #7c3aed 0%, #5b21b6 100%);--gradient-secondary: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);--gradient-success: linear-gradient(135deg, #10b981 0%, #047857 100%);--gradient-warning: linear-gradient(135deg, #f59e0b 0%, #b45309 100%);--gradient-danger: linear-gradient(135deg, #ef4444 0%, #b91c1c 100%);--gradient-info: linear-gradient(135deg, #3b82f6 0%, #1e40af 100%);--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, .3);--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, .4), 0 2px 4px -1px rgba(0, 0, 0, .2);--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, .4), 0 4px 6px -2px rgba(0, 0, 0, .2);--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, .4), 0 10px 10px -5px rgba(0, 0, 0, .2)}}*,*:before,*:after{box-sizing:border-box;margin:0;font-weight:400}body{min-height:100vh;color:var(--color-text);background:var(--color-background);transition:color .5s,background-color .5s;line-height:1.6;font-family:Inter,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Oxygen,Ubuntu,Cantarell,Fira Sans,Droid Sans,Helvetica Neue,sans-serif;font-size:15px;text-rendering:optimizeLegibility;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}#app{max-width:1280px;margin:0 auto;padding:1rem;font-weight:400}a,.green{text-decoration:none;color:#00bd7e;transition:.4s;padding:3px}@media (hover: hover){a:hover{background-color:#00bd7e33}}body{margin:0;padding:0}.stats-grid[data-v-8b643ea6]{display:grid;grid-template-columns:repeat(3,1fr);gap:15px;margin-top:15px;margin-bottom:20px}@media (max-width: 768px){.stats-grid[data-v-8b643ea6]{gap:6px}}.stat-card[data-v-8b643ea6]{background-color:var(--stats-item-bg);padding:15px;border-radius:var(--radius-lg);text-align:center;box-shadow:var(--shadow-sm);transition:all .3s ease;position:relative;overflow:hidden;border:1px solid var(--card-border)}.stat-card[data-v-8b643ea6]:before{content:"";position:absolute;top:0;left:0;width:100%;height:4px;background:var(--gradient-secondary);opacity:0;transition:opacity .3s ease}.stat-card[data-v-8b643ea6]:hover:before{opacity:1}.stat-card[data-v-8b643ea6]:hover{transform:translateY(-5px);box-shadow:var(--shadow-md);border-color:var(--button-primary)}.stat-value[data-v-8b643ea6]{font-size:24px;font-weight:700;color:var(--button-primary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;transition:all .3s ease;margin-bottom:5px}.stat-label[data-v-8b643ea6]{font-size:14px;color:var(--color-text);margin-top:5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;transition:all .3s ease;opacity:.8}@media (max-width: 768px){.stat-card[data-v-8b643ea6]{padding:8px 5px}.stat-value[data-v-8b643ea6]{font-size:16px}.stat-label[data-v-8b643ea6]{font-size:11px;margin-top:3px}}@media (max-width: 480px){.stat-card[data-v-8b643ea6]{padding:6px 3px}.stat-value[data-v-8b643ea6]{font-size:14px}.stat-label[data-v-8b643ea6]{font-size:10px;margin-top:2px}}.api-calls-chart-container[data-v-d9262b8e]{margin:20px 0;border-radius:var(--radius-lg);background-color:var(--stats-item-bg);padding:15px;box-shadow:var(--shadow-sm);border:1px solid var(--card-border);transition:all .3s ease}.api-calls-chart-container[data-v-d9262b8e]:hover{box-shadow:var(--shadow-md);border-color:var(--button-primary);transform:translateY(-3px)}.chart-title[data-v-d9262b8e]{margin-top:0;margin-bottom:15px;color:var(--color-heading);font-weight:600;text-align:center}.chart-container[data-v-d9262b8e]{width:100%;height:350px}@media (max-width: 768px){.chart-container[data-v-d9262b8e]{height:300px}}@media (max-width: 480px){.chart-container[data-v-d9262b8e]{height:250px}}.api-key-stats-container[data-v-a6a7d43b]{margin-top:20px}.header-section[data-v-a6a7d43b]{display:flex;justify-content:space-between;align-items:center;margin-bottom:15px}.header-buttons[data-v-a6a7d43b]{display:flex;gap:10px}.add-api-key-button[data-v-a6a7d43b]{display:flex;align-items:center;gap:8px;background-color:var(--button-primary);color:#fff;border:none;border-radius:var(--radius-md);padding:8px 16px;font-size:14px;font-weight:500;cursor:pointer;transition:all .3s ease;box-shadow:var(--shadow-sm)}.add-api-key-button svg[data-v-a6a7d43b]{transition:transform .3s ease;stroke:#fff}.add-api-key-button[data-v-a6a7d43b]:hover{background-color:var(--button-primary-hover);transform:translateY(-2px);box-shadow:var(--shadow-md)}.add-api-key-button:hover svg[data-v-a6a7d43b]{transform:rotate(90deg);stroke:#fff}.test-api-key-button[data-v-a6a7d43b]{display:flex;align-items:center;gap:8px;background-color:var(--button-secondary);color:var(--button-secondary-text);border:1px solid var(--color-border);border-radius:var(--radius-md);padding:8px 16px;font-size:14px;font-weight:500;cursor:pointer;transition:all .3s ease;box-shadow:var(--shadow-sm)}.test-api-key-button svg[data-v-a6a7d43b]{transition:transform .3s ease;stroke:var(--button-secondary-text)}.test-api-key-button[data-v-a6a7d43b]:hover{background-color:var(--button-secondary-hover);transform:translateY(-2px);box-shadow:var(--shadow-md)}.test-api-key-button:hover svg[data-v-a6a7d43b]{transform:rotate(15deg)}.clear-invalid-keys-button[data-v-a6a7d43b]{display:flex;align-items:center;gap:8px;background-color:var(--button-danger, #dc3545);color:#fff;border:none;border-radius:var(--radius-md);padding:8px 16px;font-size:14px;font-weight:500;cursor:pointer;transition:all .3s ease;box-shadow:var(--shadow-sm)}.clear-invalid-keys-button svg[data-v-a6a7d43b]{transition:transform .3s ease;stroke:#fff}.clear-invalid-keys-button[data-v-a6a7d43b]:hover{background-color:var(--button-danger-hover, #c82333);transform:translateY(-2px);box-shadow:var(--shadow-md)}.clear-invalid-keys-button:hover svg[data-v-a6a7d43b]{transform:scale(1.1)}.export-valid-keys-button[data-v-a6a7d43b]{display:flex;align-items:center;gap:8px;background-color:var(--button-success, #28a745);color:#fff;border:none;border-radius:var(--radius-md);padding:8px 16px;font-size:14px;font-weight:500;cursor:pointer;transition:all .3s ease;box-shadow:var(--shadow-sm)}.export-valid-keys-button svg[data-v-a6a7d43b]{transition:transform .3s ease;stroke:#fff}.export-valid-keys-button[data-v-a6a7d43b]:hover{background-color:var(--button-success-hover, #218838);transform:translateY(-2px);box-shadow:var(--shadow-md)}.export-valid-keys-button:hover svg[data-v-a6a7d43b]{transform:translateY(-1px)}.api-key-test-form[data-v-a6a7d43b],.clear-invalid-keys-form[data-v-a6a7d43b],.export-valid-keys-form[data-v-a6a7d43b]{background-color:var(--color-background-mute);border-radius:var(--radius-lg);padding:20px;margin-bottom:20px;border:1px solid var(--card-border);box-shadow:var(--shadow-md)}.exported-keys-container[data-v-a6a7d43b]{margin-top:15px}.exported-keys-header[data-v-a6a7d43b]{display:flex;justify-content:space-between;align-items:center;margin-bottom:10px}.exported-keys-header h5[data-v-a6a7d43b]{font-size:14px;font-weight:600;color:var(--color-heading);margin:0}.copy-all-button[data-v-a6a7d43b]{display:flex;align-items:center;gap:6px;background-color:var(--button-primary);color:#fff;border:none;border-radius:var(--radius-sm);padding:6px 12px;font-size:12px;font-weight:500;cursor:pointer;transition:all .3s ease}.copy-all-button[data-v-a6a7d43b]:hover{background-color:var(--button-primary-hover);transform:translateY(-1px)}.copy-all-button svg[data-v-a6a7d43b]{stroke:#fff}.exported-keys-list[data-v-a6a7d43b]{max-height:300px;overflow-y:auto;border:1px solid var(--color-border);border-radius:var(--radius-md);background-color:var(--color-background)}.exported-key-item[data-v-a6a7d43b]{display:flex;justify-content:space-between;align-items:center;padding:10px 12px;border-bottom:1px solid var(--color-border);transition:background-color .2s ease}.exported-key-item[data-v-a6a7d43b]:last-child{border-bottom:none}.exported-key-item[data-v-a6a7d43b]:hover{background-color:var(--color-background-mute)}.key-text[data-v-a6a7d43b]{font-family:Courier New,monospace;font-size:13px;color:var(--color-text);word-break:break-all;flex:1;margin-right:10px}.copy-key-button[data-v-a6a7d43b]{display:flex;align-items:center;justify-content:center;background-color:transparent;color:var(--color-text-muted);border:1px solid var(--color-border);border-radius:var(--radius-sm);padding:4px;cursor:pointer;transition:all .2s ease;min-width:28px;height:28px}.copy-key-button[data-v-a6a7d43b]:hover{background-color:var(--button-primary);color:#fff;border-color:var(--button-primary)}.copy-key-button svg[data-v-a6a7d43b]{stroke:currentColor}.form-title[data-v-a6a7d43b]{margin-bottom:15px}.form-title h4[data-v-a6a7d43b]{font-size:16px;font-weight:600;color:var(--color-heading);margin-bottom:8px}.form-description[data-v-a6a7d43b]{font-size:14px;color:var(--color-text);line-height:1.5;opacity:.8}.testing-progress[data-v-a6a7d43b]{margin:15px 0}.progress-bar-container[data-v-a6a7d43b]{height:10px;background-color:var(--color-background-soft);border-radius:var(--radius-full);overflow:hidden;margin-bottom:10px}.progress-bar-fill[data-v-a6a7d43b]{height:100%;background:var(--gradient-primary);border-radius:var(--radius-full);transition:width .3s ease;position:relative}.progress-bar-fill[data-v-a6a7d43b]:after{content:"";position:absolute;top:0;left:0;width:100%;height:100%;background:linear-gradient(90deg,transparent,rgba(255,255,255,.2),transparent);transform:translate(-100%);animation:progressShine-a6a7d43b 2s infinite}.progress-text[data-v-a6a7d43b]{font-size:14px;text-align:center;color:var(--color-heading)}.slide-enter-active[data-v-a6a7d43b],.slide-leave-active[data-v-a6a7d43b]{transition:all .3s ease;max-height:500px;opacity:1;overflow:hidden}.slide-enter-from[data-v-a6a7d43b],.slide-leave-to[data-v-a6a7d43b]{max-height:0;opacity:0;padding:0;margin:0;overflow:hidden}.stats-grid[data-v-a6a7d43b]{display:grid;grid-template-columns:repeat(3,1fr);gap:15px;margin-top:15px;margin-bottom:20px}.stat-card[data-v-a6a7d43b]{background-color:var(--stats-item-bg);padding:15px;border-radius:var(--radius-lg);text-align:center;box-shadow:var(--shadow-sm);transition:all .3s ease;position:relative;overflow:hidden;border:1px solid var(--card-border)}.stat-card[data-v-a6a7d43b]:before{content:"";position:absolute;top:0;left:0;width:100%;height:4px;background:var(--gradient-secondary);opacity:0;transition:opacity .3s ease}.stat-card[data-v-a6a7d43b]:hover:before{opacity:1}.stat-card[data-v-a6a7d43b]:hover{transform:translateY(-5px);box-shadow:var(--shadow-md);border-color:var(--button-primary)}.stat-value[data-v-a6a7d43b]{font-size:24px;font-weight:700;color:var(--button-primary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;transition:all .3s ease;margin-bottom:5px}.stat-label[data-v-a6a7d43b]{font-size:14px;color:var(--color-text);margin-top:5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;transition:all .3s ease;opacity:.8}.stats-summary[data-v-a6a7d43b]{display:flex;justify-content:space-between;margin-bottom:20px;background-color:var(--color-background-mute);border-radius:var(--radius-lg);padding:15px;border:1px solid var(--card-border)}.summary-item[data-v-a6a7d43b]{display:flex;flex-direction:column;align-items:center;flex:1}.summary-label[data-v-a6a7d43b]{font-size:12px;color:var(--color-text);opacity:.8;margin-bottom:5px}.summary-value[data-v-a6a7d43b]{font-size:18px;font-weight:600;color:var(--button-primary)}.api-key-stats-list[data-v-a6a7d43b]{display:grid;grid-template-columns:repeat(3,1fr);gap:15px;margin-top:15px}.api-key-item[data-v-a6a7d43b]{background-color:var(--stats-item-bg);border-radius:var(--radius-lg);padding:15px;box-shadow:var(--shadow-sm);transition:all .3s ease;position:relative;overflow:hidden;border:1px solid var(--card-border)}.api-key-item[data-v-a6a7d43b]:before{content:"";position:absolute;top:0;left:0;width:100%;height:4px;background:var(--gradient-info);opacity:0;transition:opacity .3s ease}.api-key-item[data-v-a6a7d43b]:hover:before{opacity:1}.api-key-item[data-v-a6a7d43b]:hover{transform:translateY(-3px);box-shadow:var(--shadow-md);border-color:var(--button-primary)}.api-key-header[data-v-a6a7d43b]{display:flex;justify-content:space-between;align-items:center;margin-bottom:10px}.api-key-name[data-v-a6a7d43b]{font-weight:700;color:var(--color-heading);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:50%;transition:all .3s ease}.api-key-usage[data-v-a6a7d43b]{display:flex;align-items:center;gap:10px;white-space:nowrap}.api-key-count[data-v-a6a7d43b]{font-weight:700;color:var(--button-primary);transition:all .3s ease}.progress-container[data-v-a6a7d43b]{width:100%;height:10px;background-color:var(--color-background-soft);border-radius:var(--radius-full);overflow:hidden;transition:all .3s ease;margin:10px 0}.progress-bar[data-v-a6a7d43b]{height:100%;border-radius:var(--radius-full);transition:width .5s ease,background-color .3s;position:relative;overflow:hidden}.progress-bar[data-v-a6a7d43b]:after{content:"";position:absolute;top:0;left:0;width:100%;height:100%;background:linear-gradient(90deg,transparent,rgba(255,255,255,.2),transparent);transform:translate(-100%);animation:progressShine-a6a7d43b 2s infinite}@keyframes progressShine-a6a7d43b{0%{transform:translate(-100%)}to{transform:translate(100%)}}.progress-bar.low[data-v-a6a7d43b]{background:var(--gradient-success)}.progress-bar.medium[data-v-a6a7d43b]{background:var(--gradient-warning)}.progress-bar.high[data-v-a6a7d43b]{background:var(--gradient-danger)}.model-stats-container[data-v-a6a7d43b]{margin-top:10px;border-top:1px dashed var(--color-border);padding-top:10px;transition:all .3s ease}.model-stats-header[data-v-a6a7d43b]{display:flex;justify-content:space-between;align-items:center;cursor:pointer;-webkit-user-select:none;user-select:none;margin-bottom:8px;color:var(--color-heading);font-size:14px;transition:all .3s ease;padding:5px 8px;border-radius:var(--radius-md)}.model-stats-header[data-v-a6a7d43b]:hover{background-color:var(--color-background-mute)}.model-stats-title[data-v-a6a7d43b]{font-weight:600}.model-stats-list[data-v-a6a7d43b]{display:flex;flex-direction:column;gap:8px}.model-stat-item[data-v-a6a7d43b]{display:flex;justify-content:space-between;align-items:flex-start;padding:10px;background-color:var(--color-background-mute);border-radius:var(--radius-md);font-size:13px;transition:all .3s ease;border:1px solid transparent}.model-stat-item[data-v-a6a7d43b]:hover{transform:translate(5px);box-shadow:var(--shadow-sm);border-color:var(--button-primary)}.model-info[data-v-a6a7d43b]{display:flex;flex-direction:column;gap:4px;width:100%}.model-name[data-v-a6a7d43b]{font-weight:500;color:var(--color-heading);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100%;transition:all .3s ease}.model-count[data-v-a6a7d43b]{display:flex;align-items:center;gap:8px;color:var(--button-primary);font-weight:600;transition:all .3s ease}.model-usage-text[data-v-a6a7d43b]{color:var(--color-text);font-weight:400;font-size:12px;transition:all .3s ease;opacity:.8}.model-tokens[data-v-a6a7d43b]{font-size:12px;color:var(--color-text);opacity:.8;transition:all .3s ease}.view-more-models[data-v-a6a7d43b]{text-align:center;color:var(--button-primary);font-size:12px;cursor:pointer;padding:8px;margin-top:5px;border-radius:var(--radius-md);background-color:#4f46e50d;transition:all .3s ease;border:1px dashed var(--button-primary)}.view-more-models[data-v-a6a7d43b]:hover{background-color:#4f46e51a;transform:translateY(-2px);box-shadow:var(--shadow-sm)}.section-title[data-v-a6a7d43b]{color:var(--color-heading);border-bottom:1px solid var(--color-border);padding-bottom:10px;transition:all .3s ease;position:relative;font-weight:600;margin:0}.section-title[data-v-a6a7d43b]:after{content:"";position:absolute;bottom:-1px;left:0;width:50px;height:2px;background:var(--gradient-primary)}.fold-header[data-v-a6a7d43b]{cursor:pointer;-webkit-user-select:none;user-select:none;display:flex;justify-content:space-between;align-items:center;transition:all .3s ease;border-radius:var(--radius-md);padding:8px 12px;background-color:var(--color-background-mute);margin-bottom:0;margin-right:10px;flex:1}.fold-header[data-v-a6a7d43b]:hover{background-color:var(--color-background-soft);transform:translateY(-2px);box-shadow:var(--shadow-sm)}.fold-icon[data-v-a6a7d43b]{display:inline-flex;align-items:center;justify-content:center;transition:transform .3s ease}.fold-icon.rotated[data-v-a6a7d43b]{transform:rotate(180deg)}.fold-content[data-v-a6a7d43b]{overflow:hidden}.fold-enter-active[data-v-a6a7d43b],.fold-leave-active[data-v-a6a7d43b]{transition:all .3s ease;max-height:1000px;opacity:1;overflow:hidden}.fold-enter-from[data-v-a6a7d43b],.fold-leave-to[data-v-a6a7d43b]{max-height:0;opacity:0;overflow:hidden}.total-tokens[data-v-a6a7d43b]{margin-top:6px;padding:8px 12px;background-color:var(--color-background-mute);border-radius:var(--radius-md);display:flex;align-items:center;gap:6px;transition:all .3s ease;border:1px solid var(--card-border)}.total-tokens[data-v-a6a7d43b]:hover{background-color:var(--color-background-soft);transform:translateY(-2px);box-shadow:var(--shadow-sm);border-color:var(--button-primary)}.total-tokens-label[data-v-a6a7d43b]{font-size:11px;color:var(--color-text);opacity:.8;white-space:nowrap;transition:all .3s ease}.total-tokens-value[data-v-a6a7d43b]{font-size:13px;font-weight:600;color:var(--button-primary);transition:all .3s ease}.pagination[data-v-a6a7d43b]{display:flex;justify-content:center;align-items:center;margin-top:20px;gap:15px}.pagination-button[data-v-a6a7d43b]{background-color:var(--button-secondary);color:var(--button-secondary-text);border:none;border-radius:var(--radius-md);padding:8px 16px;cursor:pointer;transition:all .3s ease;font-weight:500}.pagination-button[data-v-a6a7d43b]:hover:not(:disabled){background-color:var(--button-secondary-hover);transform:translateY(-2px);box-shadow:var(--shadow-sm)}.pagination-button[data-v-a6a7d43b]:disabled{opacity:.5;cursor:not-allowed}.pagination-info[data-v-a6a7d43b]{font-size:14px;color:var(--color-text)}@media (max-width: 768px){.header-section[data-v-a6a7d43b]{flex-direction:column;align-items:flex-start;gap:15px}.header-buttons[data-v-a6a7d43b]{display:grid;grid-template-columns:repeat(2,1fr);gap:8px;width:100%}.add-api-key-button[data-v-a6a7d43b],.test-api-key-button[data-v-a6a7d43b],.clear-invalid-keys-button[data-v-a6a7d43b],.export-valid-keys-button[data-v-a6a7d43b]{padding:8px 12px;font-size:12px;justify-content:center;min-height:36px;gap:6px}.add-api-key-button svg[data-v-a6a7d43b],.test-api-key-button svg[data-v-a6a7d43b],.clear-invalid-keys-button svg[data-v-a6a7d43b],.export-valid-keys-button svg[data-v-a6a7d43b]{width:14px;height:14px;flex-shrink:0}.api-key-stats-list[data-v-a6a7d43b]{grid-template-columns:1fr}.api-key-item[data-v-a6a7d43b]{padding:10px}.api-key-name[data-v-a6a7d43b]{font-size:13px;max-width:100%;color:var(--button-primary)}.api-key-usage[data-v-a6a7d43b]{font-size:12px;gap:5px}.model-stats-container[data-v-a6a7d43b]{margin-top:10px}.model-info[data-v-a6a7d43b]{gap:3px}.total-tokens-label[data-v-a6a7d43b]{color:var(--color-heading);opacity:.9}.api-key-test-form[data-v-a6a7d43b],.clear-invalid-keys-form[data-v-a6a7d43b],.export-valid-keys-form[data-v-a6a7d43b]{padding:15px}.form-title h4[data-v-a6a7d43b]{font-size:14px}.form-description[data-v-a6a7d43b],.progress-text[data-v-a6a7d43b]{font-size:12px}.exported-keys-list[data-v-a6a7d43b]{max-height:250px}.exported-key-item[data-v-a6a7d43b]{padding:8px 10px}.key-text[data-v-a6a7d43b]{font-size:12px}.copy-key-button[data-v-a6a7d43b]{min-width:24px;height:24px;padding:3px}.copy-all-button[data-v-a6a7d43b]{padding:5px 10px;font-size:11px}}@media (max-width: 480px){.header-buttons[data-v-a6a7d43b]{grid-template-columns:1fr;gap:6px}.add-api-key-button[data-v-a6a7d43b],.test-api-key-button[data-v-a6a7d43b],.clear-invalid-keys-button[data-v-a6a7d43b],.export-valid-keys-button[data-v-a6a7d43b]{padding:10px 12px;font-size:13px;min-height:40px;gap:8px}.add-api-key-button svg[data-v-a6a7d43b],.test-api-key-button svg[data-v-a6a7d43b],.clear-invalid-keys-button svg[data-v-a6a7d43b],.export-valid-keys-button svg[data-v-a6a7d43b]{width:16px;height:16px;flex-shrink:0}.api-key-test-form[data-v-a6a7d43b],.clear-invalid-keys-form[data-v-a6a7d43b],.export-valid-keys-form[data-v-a6a7d43b]{padding:12px;margin-bottom:15px}.form-title h4[data-v-a6a7d43b]{font-size:15px}.form-description[data-v-a6a7d43b]{font-size:13px}.form-actions[data-v-a6a7d43b]{flex-direction:column;gap:8px}.submit-api-key[data-v-a6a7d43b],.cancel-api-key[data-v-a6a7d43b]{width:100%;padding:10px;font-size:14px}.exported-keys-header[data-v-a6a7d43b]{flex-direction:column;align-items:flex-start;gap:8px}.copy-all-button[data-v-a6a7d43b]{align-self:flex-end}}@media (max-width: 360px){.header-buttons[data-v-a6a7d43b]{gap:4px}.add-api-key-button[data-v-a6a7d43b],.test-api-key-button[data-v-a6a7d43b],.clear-invalid-keys-button[data-v-a6a7d43b],.export-valid-keys-button[data-v-a6a7d43b]{padding:8px 10px;font-size:12px;min-height:36px;gap:6px}.add-api-key-button svg[data-v-a6a7d43b],.test-api-key-button svg[data-v-a6a7d43b],.clear-invalid-keys-button svg[data-v-a6a7d43b],.export-valid-keys-button svg[data-v-a6a7d43b]{width:14px;height:14px}.api-key-test-form[data-v-a6a7d43b],.clear-invalid-keys-form[data-v-a6a7d43b],.export-valid-keys-form[data-v-a6a7d43b]{padding:10px}.form-title h4[data-v-a6a7d43b]{font-size:14px}.form-description[data-v-a6a7d43b]{font-size:12px}.submit-api-key[data-v-a6a7d43b],.cancel-api-key[data-v-a6a7d43b]{padding:8px;font-size:13px}}@media (max-width: 992px){.api-key-stats-list[data-v-a6a7d43b]{grid-template-columns:repeat(2,1fr)}}@media (max-width: 576px){.api-key-stats-list[data-v-a6a7d43b]{grid-template-columns:1fr}}.api-key-input-form[data-v-a6a7d43b]{background-color:var(--color-background-mute);border-radius:var(--radius-lg);padding:20px;margin-bottom:20px;border:1px solid var(--card-border);box-shadow:var(--shadow-md)}.form-group[data-v-a6a7d43b]{margin-bottom:15px}.form-group label[data-v-a6a7d43b]{display:block;margin-bottom:8px;font-size:14px;font-weight:500;color:var(--color-heading)}.api-key-textarea[data-v-a6a7d43b]{width:100%;padding:10px;border:1px solid var(--color-border);border-radius:var(--radius-md);background-color:var(--color-background);color:var(--color-text);font-family:inherit;font-size:14px;resize:vertical;transition:all .3s ease}.api-key-textarea[data-v-a6a7d43b]:focus{outline:none;border-color:var(--button-primary);box-shadow:0 0 0 2px #4f46e51a}.api-key-password[data-v-a6a7d43b]{width:100%;padding:10px;border:1px solid var(--color-border);border-radius:var(--radius-md);background-color:var(--color-background);color:var(--color-text);font-family:inherit;font-size:14px;transition:all .3s ease}.api-key-password[data-v-a6a7d43b]:focus{outline:none;border-color:var(--button-primary);box-shadow:0 0 0 2px #4f46e51a}.api-key-error[data-v-a6a7d43b]{color:var(--color-danger);font-size:14px;margin-bottom:15px;padding:10px;background-color:#ef44441a;border-radius:var(--radius-md);border-left:3px solid var(--color-danger)}.api-key-success[data-v-a6a7d43b]{color:var(--color-success);font-size:14px;margin-bottom:15px;padding:10px;background-color:#22c55e1a;border-radius:var(--radius-md);border-left:3px solid var(--color-success)}.form-actions[data-v-a6a7d43b]{display:flex;gap:10px}.submit-api-key[data-v-a6a7d43b]{padding:8px 20px;background-color:var(--button-primary);color:#fff;border:none;border-radius:var(--radius-md);font-weight:500;cursor:pointer;transition:all .3s ease}.submit-api-key[data-v-a6a7d43b]:hover:not(:disabled){background-color:var(--button-primary-hover);transform:translateY(-2px);box-shadow:var(--shadow-sm)}.submit-api-key[data-v-a6a7d43b]:disabled{opacity:.5;cursor:not-allowed}.cancel-api-key[data-v-a6a7d43b]{padding:8px 20px;background-color:var(--button-secondary);color:var(--button-secondary-text);border:none;border-radius:var(--radius-md);font-weight:500;cursor:pointer;transition:all .3s ease}.cancel-api-key[data-v-a6a7d43b]:hover:not(:disabled){background-color:var(--button-secondary-hover);transform:translateY(-2px);box-shadow:var(--shadow-sm)}.cancel-api-key[data-v-a6a7d43b]:disabled{opacity:.5;cursor:not-allowed}.info-box[data-v-257ff3a7]{background-color:var(--card-background);border:1px solid var(--card-border);border-radius:var(--radius-xl);padding:20px;margin-bottom:20px;box-shadow:var(--shadow-md);transition:all .3s ease;position:relative;overflow:hidden}.info-box[data-v-257ff3a7]:before{content:"";position:absolute;top:0;left:0;width:4px;height:100%;background:var(--gradient-success);opacity:.8}@media (max-width: 768px){.info-box[data-v-257ff3a7]{margin-bottom:12px;padding:15px 10px;border-radius:var(--radius-lg)}}@media (max-width: 480px){.info-box[data-v-257ff3a7]{margin-bottom:8px;padding:12px 8px;border-radius:var(--radius-md)}}.section-header[data-v-257ff3a7]{display:flex;justify-content:space-between;align-items:center;margin-bottom:10px;position:relative}.status-container[data-v-257ff3a7]{display:flex;align-items:center;justify-content:center;position:absolute;left:50%;transform:translate(-50%)}.reset-button[data-v-257ff3a7]{display:flex;align-items:center;gap:5px;background-color:var(--button-secondary);color:var(--button-secondary-text);border:none;border-radius:var(--radius-md);padding:8px 12px;font-size:14px;cursor:pointer;transition:all .3s ease;position:relative;overflow:hidden;box-shadow:var(--shadow-sm);height:100%;z-index:1}.reset-button[data-v-257ff3a7]:before{content:"";position:absolute;top:0;left:0;width:100%;height:100%;background:linear-gradient(90deg,transparent,rgba(255,255,255,.1),transparent);transform:translate(-100%);transition:transform .6s ease}.reset-button[data-v-257ff3a7]:hover:before{transform:translate(100%)}.reset-button[data-v-257ff3a7]:hover{background-color:var(--button-secondary-hover);transform:translateY(-2px);box-shadow:var(--shadow-md)}.reset-button svg[data-v-257ff3a7]{transition:transform .3s}.reset-button:hover svg[data-v-257ff3a7]{transform:rotate(180deg)}.dialog-overlay[data-v-257ff3a7]{position:fixed;top:0;left:0;right:0;bottom:0;background-color:#00000080;display:flex;justify-content:center;align-items:flex-start;z-index:1000;padding-top:20px;-webkit-backdrop-filter:blur(5px);backdrop-filter:blur(5px)}.dialog[data-v-257ff3a7]{background-color:var(--card-background);border-radius:var(--radius-xl);padding:20px;width:90%;max-width:400px;box-shadow:var(--shadow-xl);margin-top:20px;position:relative;overflow:hidden;animation:dialogAppear-257ff3a7 .3s ease forwards}@keyframes dialogAppear-257ff3a7{0%{opacity:0;transform:translateY(-20px) scale(.95)}to{opacity:1;transform:translateY(0) scale(1)}}.dialog[data-v-257ff3a7]:before{content:"";position:absolute;top:0;left:0;width:100%;height:4px;background:var(--gradient-primary)}.dialog h3[data-v-257ff3a7]{margin-top:0;margin-bottom:10px;color:var(--color-heading);font-size:1.2rem;font-weight:600}.dialog p[data-v-257ff3a7]{margin-bottom:15px;color:var(--color-text);font-size:14px;line-height:1.5}.dialog input[data-v-257ff3a7]{width:100%;padding:12px 16px;border:1px solid var(--color-border);border-radius:var(--radius-md);margin-bottom:15px;background-color:var(--color-background);color:var(--color-text);transition:all .3s ease;font-size:14px}.dialog input[data-v-257ff3a7]:focus{outline:none;border-color:var(--button-primary);box-shadow:0 0 0 3px #4f46e533}.error-message[data-v-257ff3a7]{color:#ef4444;margin-bottom:15px;font-size:14px;padding:8px 12px;background-color:#ef44441a;border-radius:var(--radius-md);border-left:3px solid #ef4444}.dialog-buttons[data-v-257ff3a7]{display:flex;justify-content:flex-end;gap:10px}.cancel-button[data-v-257ff3a7]{background-color:var(--button-secondary);color:var(--button-secondary-text);border:none;border-radius:var(--radius-md);padding:10px 18px;cursor:pointer;transition:all .3s ease;font-weight:500}.cancel-button[data-v-257ff3a7]:hover{background-color:var(--button-secondary-hover);transform:translateY(-2px);box-shadow:var(--shadow-sm)}.confirm-button[data-v-257ff3a7]{background:var(--gradient-primary);color:#fff;border:none;border-radius:var(--radius-md);padding:10px 18px;cursor:pointer;transition:all .3s ease;font-weight:500;box-shadow:var(--shadow-sm)}.confirm-button[data-v-257ff3a7]:hover:not(:disabled){transform:translateY(-2px);box-shadow:var(--shadow-md)}.confirm-button[data-v-257ff3a7]:disabled{opacity:.7;cursor:not-allowed;transform:none;box-shadow:none}.status[data-v-257ff3a7]{color:#10b981;font-weight:700;font-size:16px;padding:8px 12px;background-color:#10b9811a;border-radius:var(--radius-md);border-left:none;transition:all .3s ease;animation:pulse-257ff3a7 2s infinite;margin:0;white-space:nowrap}@keyframes pulse-257ff3a7{0%{box-shadow:0 0 #10b98166}70%{box-shadow:0 0 0 10px #10b98100}to{box-shadow:0 0 #10b98100}}.section-title[data-v-257ff3a7]{color:var(--color-heading);border-bottom:1px solid var(--color-border);padding-bottom:10px;transition:all .3s ease;position:relative;font-weight:600;margin:0}.section-title[data-v-257ff3a7]:after{content:"";position:absolute;bottom:-1px;left:0;width:50px;height:2px;background:var(--gradient-primary)}.vertex-notice[data-v-257ff3a7]{background-color:var(--color-background-soft);border-radius:var(--radius-lg);padding:16px;margin:20px 0;display:flex;gap:16px;align-items:flex-start;border:1px solid var(--color-border);transition:all .3s ease;position:relative;overflow:hidden}.vertex-notice[data-v-257ff3a7]:before{content:"";position:absolute;top:0;left:0;width:4px;height:100%;background:var(--gradient-info);opacity:.8}.vertex-notice[data-v-257ff3a7]:hover{transform:translateY(-3px);box-shadow:var(--shadow-md);border-color:var(--button-primary)}.notice-icon[data-v-257ff3a7]{font-size:24px;background-color:var(--color-background-mute);padding:8px;border-radius:50%;display:flex;align-items:center;justify-content:center;min-width:40px;height:40px;transition:all .3s ease;box-shadow:var(--shadow-sm)}.notice-content[data-v-257ff3a7]{flex:1}.notice-title[data-v-257ff3a7]{color:var(--color-heading);font-size:16px;font-weight:600;margin:0 0 8px;transition:all .3s ease}.notice-text[data-v-257ff3a7]{color:var(--color-text);font-size:14px;line-height:1.5;margin:0;transition:all .3s ease}@media (max-width: 768px){.vertex-notice[data-v-257ff3a7]{padding:12px;gap:12px}.notice-icon[data-v-257ff3a7]{font-size:20px;min-width:32px;height:32px;padding:6px}.notice-title[data-v-257ff3a7]{font-size:14px;margin-bottom:6px}.notice-text[data-v-257ff3a7]{font-size:12px}.status[data-v-257ff3a7]{font-size:14px;padding:6px 10px}.reset-button[data-v-257ff3a7]{font-size:12px;padding:6px 10px}}@media (max-width: 480px){.status[data-v-257ff3a7]{font-size:12px;padding:4px 8px}.reset-button[data-v-257ff3a7]{font-size:11px;padding:4px 8px}.section-header[data-v-257ff3a7]{flex-direction:row;align-items:center;justify-content:space-between;gap:8px;position:relative;padding-top:0}.section-title[data-v-257ff3a7]{font-size:14px;margin-right:auto}.status-container[data-v-257ff3a7]{position:static;transform:none;margin:0}.reset-button[data-v-257ff3a7]{align-self:center}}.section-title[data-v-bf7ce7b9]{color:var(--color-heading);border-bottom:1px solid var(--color-border);padding-bottom:10px;margin-bottom:20px;transition:all .3s ease;position:relative;font-weight:600}.section-title[data-v-bf7ce7b9]:after{content:"";position:absolute;bottom:-1px;left:0;width:50px;height:2px;background:var(--gradient-primary)}.basic-config[data-v-bf7ce7b9]{margin-bottom:25px}.config-form[data-v-bf7ce7b9]{background-color:var(--stats-item-bg);border-radius:var(--radius-lg);padding:20px;box-shadow:var(--shadow-sm);border:1px solid var(--card-border)}.config-row[data-v-bf7ce7b9]{display:flex;gap:15px;margin-bottom:15px;flex-wrap:wrap}.config-group[data-v-bf7ce7b9]{flex:1;min-width:120px}.config-label[data-v-bf7ce7b9]{display:block;font-size:14px;margin-bottom:5px;color:var(--color-text);font-weight:500}.config-input[data-v-bf7ce7b9]{width:100%;padding:8px 12px;border:1px solid var(--color-border);border-radius:var(--radius-md);background-color:var(--color-background);color:var(--color-text);font-size:14px;transition:all .3s ease}.config-input[data-v-bf7ce7b9]:focus{outline:none;border-color:var(--button-primary);box-shadow:0 0 0 2px #4f46e533}@media (max-width: 768px){.config-row[data-v-bf7ce7b9]{gap:10px}.config-group[data-v-bf7ce7b9]{min-width:100px}}@media (max-width: 480px){.config-row[data-v-bf7ce7b9]{flex-direction:column;gap:10px}.config-group[data-v-bf7ce7b9]{width:100%}.config-form[data-v-bf7ce7b9]{padding:15px}}.section-title[data-v-dc697ea9]{color:var(--color-heading);border-bottom:1px solid var(--color-border);padding-bottom:10px;margin-bottom:20px;transition:all .3s ease;position:relative;font-weight:600}.section-title[data-v-dc697ea9]:after{content:"";position:absolute;bottom:-1px;left:0;width:50px;height:2px;background:var(--gradient-primary)}.features-config[data-v-dc697ea9]{margin-bottom:25px}.config-form[data-v-dc697ea9]{background-color:var(--stats-item-bg);border-radius:var(--radius-lg);padding:20px;box-shadow:var(--shadow-sm);border:1px solid var(--card-border)}.config-row[data-v-dc697ea9]{display:flex;gap:15px;margin-bottom:15px;flex-wrap:wrap}.config-group[data-v-dc697ea9]{flex:1;min-width:120px}.full-width[data-v-dc697ea9]{flex-basis:100%}.config-label[data-v-dc697ea9]{display:block;font-size:14px;margin-bottom:5px;color:var(--color-text);font-weight:500}.config-input[data-v-dc697ea9]{width:100%;padding:8px 12px;border:1px solid var(--color-border);border-radius:var(--radius-md);background-color:var(--color-background);color:var(--color-text);font-size:14px;transition:all .3s ease}.config-input[data-v-dc697ea9]:focus{outline:none;border-color:var(--button-primary);box-shadow:0 0 0 2px #4f46e533}.toggle-wrapper[data-v-dc697ea9]{position:relative}.toggle[data-v-dc697ea9]{position:absolute;opacity:0;width:0;height:0}.toggle-label[data-v-dc697ea9]{display:flex;align-items:center;cursor:pointer;-webkit-user-select:none;user-select:none}.toggle-label[data-v-dc697ea9]:before{content:"";display:inline-block;width:36px;height:20px;background-color:var(--color-border);border-radius:10px;margin-right:8px;position:relative;transition:all .3s ease}.toggle-label[data-v-dc697ea9]:after{content:"";position:absolute;left:3px;width:14px;height:14px;background-color:#fff;border-radius:50%;transition:all .3s ease}.toggle:checked+.toggle-label[data-v-dc697ea9]:before{background-color:var(--button-primary)}.toggle:checked+.toggle-label[data-v-dc697ea9]:after{left:19px}.toggle-text[data-v-dc697ea9]{font-size:14px;color:var(--color-text)}@media (max-width: 768px){.config-row[data-v-dc697ea9]{gap:10px}.config-group[data-v-dc697ea9]{min-width:100px}}@media (max-width: 480px){.config-row[data-v-dc697ea9]{flex-direction:column;gap:10px}.config-group[data-v-dc697ea9]{width:100%}.config-form[data-v-dc697ea9]{padding:15px}}.section-title[data-v-e1a72b08]{color:var(--color-heading);border-bottom:1px solid var(--color-border);padding-bottom:10px;margin-bottom:20px;transition:all .3s ease;position:relative;font-weight:600}.section-title[data-v-e1a72b08]:after{content:"";position:absolute;bottom:-1px;left:0;width:50px;height:2px;background:var(--gradient-primary)}.stats-grid[data-v-e1a72b08]{display:grid;grid-template-columns:repeat(3,1fr);gap:15px;margin-top:15px;margin-bottom:20px}@media (max-width: 768px){.stats-grid[data-v-e1a72b08]{gap:8px}}.stat-card[data-v-e1a72b08]{background-color:var(--stats-item-bg);padding:10px 15px;border-radius:var(--radius-lg);text-align:center;box-shadow:var(--shadow-sm);transition:all .3s ease;position:relative;overflow:hidden;border:1px solid var(--card-border)}.stat-card[data-v-e1a72b08]:before{content:"";position:absolute;top:0;left:0;width:100%;height:3px;background:var(--gradient-secondary);opacity:0;transition:opacity .3s ease}.stat-card[data-v-e1a72b08]:hover{transform:translateY(-3px);box-shadow:var(--shadow-md);border-color:var(--button-primary)}.stat-card[data-v-e1a72b08]:hover:before{opacity:1}.stat-value[data-v-e1a72b08]{font-size:24px;font-weight:700;color:var(--button-primary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;transition:all .3s ease;margin-bottom:5px;position:relative;display:inline-block}.stat-label[data-v-e1a72b08]{font-size:14px;color:var(--color-text);margin-top:5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;transition:all .3s ease;opacity:.8}.stat-card:hover .stat-label[data-v-e1a72b08]{opacity:1;color:var(--color-heading)}.update-status-container[data-v-e1a72b08]{display:flex;align-items:center;justify-content:center;min-height:40px;width:100%}.update-status[data-v-e1a72b08]{display:flex;align-items:center;justify-content:center;gap:8px;padding:8px 12px;border-radius:var(--radius-lg);transition:all .3s ease;width:100%;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.update-status .status-icon[data-v-e1a72b08]{font-size:1.2em;display:inline-flex;align-items:center;justify-content:center;flex-shrink:0}.update-status .status-text[data-v-e1a72b08]{font-size:1em;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.update-status .status-icon.update-needed[data-v-e1a72b08],.update-status .status-text.update-needed[data-v-e1a72b08]{color:#ef4444}.update-status .status-icon.up-to-date[data-v-e1a72b08],.update-status .status-text.up-to-date[data-v-e1a72b08]{color:#10b981}.project-link-container[data-v-e1a72b08]{display:flex;justify-content:center;align-items:center;padding:15px;margin-top:20px;transition:all .3s ease}.project-link[data-v-e1a72b08]{display:flex;align-items:center;gap:10px;color:var(--button-primary);text-decoration:none;font-size:14px;padding:10px 18px;border-radius:var(--radius-full);background-color:var(--stats-item-bg);transition:all .3s ease;box-shadow:var(--shadow-sm);border:1px solid var(--card-border);position:relative;overflow:hidden}.project-link[data-v-e1a72b08]:before{content:"";position:absolute;top:0;left:0;width:100%;height:100%;background:linear-gradient(90deg,transparent,rgba(255,255,255,.1),transparent);transform:translate(-100%);transition:transform .6s ease}.project-link[data-v-e1a72b08]:hover{transform:translateY(-3px);box-shadow:var(--shadow-md);background-color:var(--color-background-mute);border-color:var(--button-primary)}.project-link[data-v-e1a72b08]:hover:before{transform:translate(100%)}.github-icon[data-v-e1a72b08]{font-size:18px;opacity:.8;transition:all .3s ease}.project-link:hover .github-icon[data-v-e1a72b08]{opacity:1;transform:scale(1.2) rotate(10deg)}.project-text[data-v-e1a72b08]{font-weight:500;position:relative}.project-text[data-v-e1a72b08]:after{content:"";position:absolute;bottom:-2px;left:0;width:0;height:1px;background:var(--gradient-primary);transition:width .3s ease}.project-link:hover .project-text[data-v-e1a72b08]:after{width:100%}@media (max-width: 768px){.stat-card[data-v-e1a72b08]{padding:8px}.stat-value[data-v-e1a72b08]{font-size:16px}.stat-label[data-v-e1a72b08]{font-size:12px;margin-top:2px}.update-status[data-v-e1a72b08]{padding:6px 10px}.update-status .status-icon[data-v-e1a72b08]{font-size:1.1em}.update-status .status-text[data-v-e1a72b08]{font-size:.9em}.project-link[data-v-e1a72b08]{font-size:12px;padding:8px 14px}.github-icon[data-v-e1a72b08]{font-size:16px}}@media (max-width: 480px){.stat-card[data-v-e1a72b08]{padding:6px}.stat-value[data-v-e1a72b08]{font-size:14px}.stat-label[data-v-e1a72b08]{font-size:11px;margin-top:1px}.update-status[data-v-e1a72b08]{padding:4px 8px}.update-status .status-icon[data-v-e1a72b08]{font-size:1em}.update-status .status-text[data-v-e1a72b08]{font-size:.85em}.project-link[data-v-e1a72b08]{font-size:11px;padding:6px 12px}.github-icon[data-v-e1a72b08]{font-size:14px}}.section-title[data-v-87fa5cbc]{color:var(--color-heading);border-bottom:1px solid var(--color-border);padding-bottom:10px;margin-bottom:20px;transition:all .3s ease;position:relative;font-weight:600}.section-title[data-v-87fa5cbc]:after{content:"";position:absolute;bottom:-1px;left:0;width:50px;height:2px;background:var(--gradient-primary)}.vertex-config[data-v-87fa5cbc]{margin-bottom:25px}.config-form[data-v-87fa5cbc]{background-color:var(--stats-item-bg);border-radius:var(--radius-lg);padding:20px;box-shadow:var(--shadow-sm);border:1px solid var(--card-border)}.config-row[data-v-87fa5cbc]{display:flex;gap:15px;margin-bottom:15px;flex-wrap:wrap}.config-group[data-v-87fa5cbc]{flex:1;min-width:120px}.full-width[data-v-87fa5cbc]{flex-basis:100%}.config-label[data-v-87fa5cbc]{display:block;font-size:14px;margin-bottom:5px;color:var(--color-text);font-weight:500}.config-input[data-v-87fa5cbc]{width:100%;padding:8px 12px;border:1px solid var(--color-border);border-radius:var(--radius-md);background-color:var(--color-background);color:var(--color-text);font-size:14px;transition:all .3s ease}.config-input[data-v-87fa5cbc]:focus{outline:none;border-color:var(--button-primary);box-shadow:0 0 0 2px #4f46e533}.text-area[data-v-87fa5cbc]{resize:vertical;min-height:80px;font-family:inherit;line-height:1.5}.toggle-wrapper[data-v-87fa5cbc]{position:relative}.toggle[data-v-87fa5cbc]{position:absolute;opacity:0;width:0;height:0}.toggle-label[data-v-87fa5cbc]{display:flex;align-items:center;cursor:pointer;-webkit-user-select:none;user-select:none}.toggle-label[data-v-87fa5cbc]:before{content:"";display:inline-block;width:36px;height:20px;background-color:var(--color-border);border-radius:10px;margin-right:8px;position:relative;transition:all .3s ease}.toggle-label[data-v-87fa5cbc]:after{content:"";position:absolute;left:3px;width:14px;height:14px;background-color:#fff;border-radius:50%;transition:all .3s ease}.toggle:checked+.toggle-label[data-v-87fa5cbc]:before{background-color:var(--button-primary)}.toggle:checked+.toggle-label[data-v-87fa5cbc]:after{left:19px}.toggle-text[data-v-87fa5cbc]{font-size:14px;color:var(--color-text)}.save-section[data-v-87fa5cbc]{display:flex;gap:10px;margin-top:20px;align-items:center}.password-input[data-v-87fa5cbc]{flex:1}.save-button[data-v-87fa5cbc]{padding:8px 16px;background:var(--button-primary);color:#fff;border:none;border-radius:var(--radius-md);cursor:pointer;font-weight:500;transition:all .3s ease}.save-button[data-v-87fa5cbc]:hover{background:var(--button-primary-hover);transform:translateY(-2px)}.save-button[data-v-87fa5cbc]:disabled{opacity:.7;cursor:not-allowed;transform:none}.error-message[data-v-87fa5cbc]{color:var(--color-error);margin-top:10px;font-size:14px;padding:8px;background-color:var(--color-error-bg);border-radius:var(--radius-md)}.success-message[data-v-87fa5cbc]{color:var(--color-success);margin-top:10px;font-size:14px;padding:8px;background-color:var(--color-success-bg);border-radius:var(--radius-md)}@media (max-width: 768px){.config-row[data-v-87fa5cbc]{gap:10px}.config-group[data-v-87fa5cbc]{min-width:100px}.save-section[data-v-87fa5cbc]{flex-direction:column}.password-input[data-v-87fa5cbc]{width:100%;margin-bottom:10px}.save-button[data-v-87fa5cbc]{width:100%}}@media (max-width: 480px){.config-row[data-v-87fa5cbc]{flex-direction:column;gap:10px}.config-group[data-v-87fa5cbc]{width:100%}.config-form[data-v-87fa5cbc]{padding:15px}}.info-box[data-v-d8cf8b0b]{background-color:var(--card-background);border:1px solid var(--card-border);border-radius:var(--radius-xl);padding:20px;margin-bottom:20px;box-shadow:var(--shadow-md);transition:all .3s ease;position:relative;overflow:hidden}.info-box[data-v-d8cf8b0b]:before{content:"";position:absolute;top:0;left:0;width:4px;height:100%;background:var(--gradient-primary);opacity:.8}@media (max-width: 768px){.info-box[data-v-d8cf8b0b]{margin-bottom:12px;padding:15px 10px;border-radius:var(--radius-lg)}}@media (max-width: 480px){.info-box[data-v-d8cf8b0b]{margin-bottom:8px;padding:12px 8px;border-radius:var(--radius-md)}}.section-title[data-v-d8cf8b0b]{color:var(--color-heading);border-bottom:1px solid var(--color-border);padding-bottom:10px;margin-bottom:20px;transition:all .3s ease;position:relative;font-weight:600}.section-title[data-v-d8cf8b0b]:after{content:"";position:absolute;bottom:-1px;left:0;width:50px;height:2px;background:var(--gradient-primary)}.stats-grid[data-v-d8cf8b0b]{display:grid;grid-template-columns:repeat(3,1fr);gap:15px;margin-top:15px;margin-bottom:20px}@media (max-width: 768px){.stats-grid[data-v-d8cf8b0b]{gap:8px}}.stat-card[data-v-d8cf8b0b]{background-color:var(--stats-item-bg);padding:10px 15px;border-radius:var(--radius-lg);text-align:center;box-shadow:var(--shadow-sm);transition:all .3s ease;position:relative;overflow:hidden;border:1px solid var(--card-border)}.stat-card[data-v-d8cf8b0b]:before{content:"";position:absolute;top:0;left:0;width:100%;height:3px;background:var(--gradient-secondary);opacity:0;transition:opacity .3s ease}.stat-card[data-v-d8cf8b0b]:hover{transform:translateY(-3px);box-shadow:var(--shadow-md);border-color:var(--button-primary)}.stat-card[data-v-d8cf8b0b]:hover:before{opacity:1}.stat-value[data-v-d8cf8b0b]{font-size:24px;font-weight:700;color:var(--button-primary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;transition:all .3s ease;margin-bottom:5px;position:relative;display:inline-block}.stat-label[data-v-d8cf8b0b]{font-size:14px;color:var(--color-text);margin-top:5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;transition:all .3s ease;opacity:.8}.stat-card:hover .stat-label[data-v-d8cf8b0b]{opacity:1;color:var(--color-heading)}.edit-btn[data-v-d8cf8b0b]{position:absolute;top:5px;right:5px;background:none;border:none;color:var(--color-text-muted);cursor:pointer;opacity:.5;transition:all .3s ease;padding:4px;border-radius:var(--radius-md);display:flex;align-items:center;justify-content:center;z-index:2}.edit-btn[data-v-d8cf8b0b]:hover{opacity:1;transform:scale(1.1) rotate(15deg);background-color:var(--color-background-mute);color:var(--button-primary)}.edit-dialog[data-v-d8cf8b0b]{position:fixed;top:0;left:0;right:0;bottom:0;background-color:#00000080;display:flex;align-items:center;justify-content:center;z-index:1000;-webkit-backdrop-filter:blur(5px);backdrop-filter:blur(5px)}.edit-dialog-content[data-v-d8cf8b0b]{background-color:var(--card-background);border-radius:var(--radius-xl);padding:25px;width:90%;max-width:400px;box-shadow:var(--shadow-xl);position:relative;overflow:hidden;animation:dialogAppear-d8cf8b0b .3s cubic-bezier(.34,1.56,.64,1)}@keyframes dialogAppear-d8cf8b0b{0%{opacity:0;transform:scale(.9) translateY(20px)}to{opacity:1;transform:scale(1) translateY(0)}}.edit-dialog-content[data-v-d8cf8b0b]:before{content:"";position:absolute;top:0;left:0;width:100%;height:4px;background:var(--gradient-primary)}.edit-dialog-content h3[data-v-d8cf8b0b]{margin-top:0;margin-bottom:15px;color:var(--color-heading);font-size:1.3rem;position:relative;padding-bottom:10px}.edit-dialog-content h3[data-v-d8cf8b0b]:after{content:"";position:absolute;bottom:0;left:0;width:40px;height:2px;background:var(--gradient-primary)}.edit-field[data-v-d8cf8b0b]{margin-bottom:20px}.edit-field label[data-v-d8cf8b0b]{display:block;margin-bottom:8px;color:var(--color-text);font-size:14px;line-height:1.5}.edit-input[data-v-d8cf8b0b]{width:100%;padding:12px 16px;border:1px solid var(--color-border);border-radius:var(--radius-md);background-color:var(--color-background);color:var(--color-text);font-size:14px;transition:all .3s ease}.edit-input[data-v-d8cf8b0b]:focus{outline:none;border-color:var(--button-primary);box-shadow:0 0 0 3px #4f46e533}.text-area[data-v-d8cf8b0b]{resize:vertical;min-height:80px;font-family:inherit;line-height:1.5}.boolean-selector[data-v-d8cf8b0b]{display:flex;gap:15px;margin-top:12px}.boolean-option[data-v-d8cf8b0b]{display:flex;align-items:center;gap:8px;cursor:pointer;padding:8px 12px;border-radius:var(--radius-md);background-color:var(--stats-item-bg);transition:all .3s ease;border:1px solid var(--color-border)}.boolean-option[data-v-d8cf8b0b]:hover{background-color:var(--color-background-mute);transform:translateY(-2px)}.boolean-option input[type=radio][data-v-d8cf8b0b]{accent-color:var(--button-primary)}.password-field[data-v-d8cf8b0b]{margin-top:15px;position:relative}.password-field label[data-v-d8cf8b0b]{margin-bottom:8px;display:block}.edit-error[data-v-d8cf8b0b]{color:#ef4444;font-size:12px;margin-top:8px;padding-left:5px;display:flex;align-items:center;gap:5px}.edit-error[data-v-d8cf8b0b]:before{content:"⚠️";font-size:14px}.edit-actions[data-v-d8cf8b0b]{display:flex;justify-content:flex-end;gap:10px;margin-top:20px}.cancel-btn[data-v-d8cf8b0b],.save-btn[data-v-d8cf8b0b]{padding:10px 18px;border-radius:var(--radius-md);font-size:14px;cursor:pointer;transition:all .3s ease;font-weight:500}.cancel-btn[data-v-d8cf8b0b]{background-color:var(--button-secondary);border:1px solid var(--color-border);color:var(--button-secondary-text)}.save-btn[data-v-d8cf8b0b]{background:var(--gradient-primary);border:none;color:#fff;box-shadow:var(--shadow-sm)}.cancel-btn[data-v-d8cf8b0b]:hover{background-color:var(--button-secondary-hover);transform:translateY(-2px)}.save-btn[data-v-d8cf8b0b]:hover{transform:translateY(-2px);box-shadow:var(--shadow-md)}.tooltip[data-v-d8cf8b0b]{position:fixed;background-color:#000c;color:#fff;padding:8px 12px;border-radius:var(--radius-md);font-size:12px;max-width:250px;z-index:1000;pointer-events:none;transform:translate(-50%,-100%);margin-top:-10px;box-shadow:var(--shadow-lg);-webkit-backdrop-filter:blur(5px);backdrop-filter:blur(5px);border:1px solid rgba(255,255,255,.1);animation:tooltipAppear-d8cf8b0b .2s ease}@keyframes tooltipAppear-d8cf8b0b{0%{opacity:0;transform:translate(-50%,-90%)}to{opacity:1;transform:translate(-50%,-100%)}}@media (max-width: 768px){.stat-card[data-v-d8cf8b0b]{padding:8px}.stat-value[data-v-d8cf8b0b]{font-size:16px}.stat-label[data-v-d8cf8b0b]{font-size:12px;margin-top:2px}.edit-btn[data-v-d8cf8b0b]{top:3px;right:3px;padding:2px}.edit-dialog-content[data-v-d8cf8b0b]{padding:20px}.boolean-selector[data-v-d8cf8b0b]{flex-direction:column;gap:8px}}@media (max-width: 480px){.stat-card[data-v-d8cf8b0b]{padding:6px}.stat-value[data-v-d8cf8b0b]{font-size:14px}.stat-label[data-v-d8cf8b0b]{font-size:11px;margin-top:1px}.tooltip[data-v-d8cf8b0b]{max-width:200px;font-size:10px}.edit-dialog-content[data-v-d8cf8b0b]{padding:15px}.edit-dialog-content h3[data-v-d8cf8b0b]{font-size:1.1rem}.edit-input[data-v-d8cf8b0b]{padding:10px 14px;font-size:13px}.cancel-btn[data-v-d8cf8b0b],.save-btn[data-v-d8cf8b0b]{padding:8px 14px;font-size:13px}}.fold-header[data-v-d8cf8b0b]{cursor:pointer;-webkit-user-select:none;user-select:none;display:flex;justify-content:space-between;align-items:center;transition:all .3s ease;border-radius:var(--radius-lg);padding:10px 15px;background-color:var(--stats-item-bg);border:1px solid var(--card-border);margin-bottom:15px}.fold-header[data-v-d8cf8b0b]:hover{background-color:var(--color-background-mute);transform:translateY(-2px);box-shadow:var(--shadow-sm)}.fold-icon[data-v-d8cf8b0b]{display:inline-flex;align-items:center;justify-content:center;transition:transform .3s ease;color:var(--button-primary)}.fold-icon.rotated[data-v-d8cf8b0b]{transform:rotate(180deg)}.fold-content[data-v-d8cf8b0b]{overflow:hidden}.fold-enter-active[data-v-d8cf8b0b],.fold-leave-active[data-v-d8cf8b0b]{transition:all .3s ease;max-height:1000px;opacity:1;overflow:hidden}.fold-enter-from[data-v-d8cf8b0b],.fold-leave-to[data-v-d8cf8b0b]{max-height:0;opacity:0;overflow:hidden}@media (max-width: 768px){.fold-header[data-v-d8cf8b0b]{padding:8px 12px}}@media (max-width: 480px){.fold-header[data-v-d8cf8b0b]{padding:6px 10px}}.shared-save-section[data-v-d8cf8b0b]{margin-top:30px;padding-top:20px;border-top:1px solid var(--color-border);display:flex;flex-direction:column;gap:15px}.password-input-group[data-v-d8cf8b0b]{display:flex;flex-direction:column;gap:5px}.shared-password-label[data-v-d8cf8b0b]{font-size:14px;color:var(--color-text);font-weight:500}.config-input[data-v-d8cf8b0b]{width:100%;padding:10px 14px;border:1px solid var(--color-border);border-radius:var(--radius-md);background-color:var(--color-background);color:var(--color-text);font-size:14px;transition:all .3s ease}.config-input[data-v-d8cf8b0b]:focus{outline:none;border-color:var(--button-primary);box-shadow:0 0 0 2px #4f46e533}.save-all-button[data-v-d8cf8b0b]{padding:10px 18px;background:var(--button-primary);color:#fff;border:none;border-radius:var(--radius-md);cursor:pointer;font-weight:500;transition:all .3s ease;text-align:center}.save-all-button[data-v-d8cf8b0b]:hover{background:var(--button-primary-hover);transform:translateY(-2px);box-shadow:var(--shadow-md)}.save-all-button[data-v-d8cf8b0b]:disabled{opacity:.7;cursor:not-allowed;transform:none}.overall-error-message[data-v-d8cf8b0b]{color:var(--color-error);margin-top:10px;font-size:14px;padding:10px;background-color:var(--color-error-bg);border-radius:var(--radius-md);border:1px solid var(--color-error)}.overall-success-message[data-v-d8cf8b0b]{color:var(--color-success);margin-top:10px;font-size:14px;padding:10px;background-color:var(--color-success-bg);border-radius:var(--radius-md);border:1px solid var(--color-success)}.info-box[data-v-d38f4a7d]{background-color:var(--card-background);border:1px solid var(--card-border);border-radius:var(--radius-xl);padding:20px;margin-bottom:20px;box-shadow:var(--shadow-md);transition:all .3s ease;position:relative;overflow:hidden}.info-box[data-v-d38f4a7d]:before{content:"";position:absolute;top:0;left:0;width:4px;height:100%;background:var(--gradient-info);opacity:.8}@media (max-width: 768px){.info-box[data-v-d38f4a7d]{margin-bottom:12px;padding:15px 10px;border-radius:var(--radius-lg)}}@media (max-width: 480px){.info-box[data-v-d38f4a7d]{margin-bottom:8px;padding:12px 8px;border-radius:var(--radius-md)}}.section-title[data-v-d38f4a7d]{color:var(--color-heading);border-bottom:1px solid var(--color-border);padding-bottom:10px;margin-bottom:20px;transition:all .3s ease;position:relative;font-weight:600}.section-title[data-v-d38f4a7d]:after{content:"";position:absolute;bottom:-1px;left:0;width:50px;height:2px;background:var(--gradient-info)}.log-filter[data-v-d38f4a7d]{display:flex;justify-content:center;margin-bottom:15px;gap:10px;flex-wrap:wrap}.log-filter button[data-v-d38f4a7d]{padding:8px 12px;border:1px solid var(--card-border);border-radius:var(--radius-md);background-color:var(--stats-item-bg);color:var(--color-text);cursor:pointer;min-width:70px;transition:all .3s ease;font-weight:500;position:relative;overflow:hidden}.log-filter button[data-v-d38f4a7d]:before{content:"";position:absolute;top:0;left:0;width:100%;height:100%;background:linear-gradient(90deg,transparent,rgba(255,255,255,.1),transparent);transform:translate(-100%);transition:transform .6s ease}.log-filter button[data-v-d38f4a7d]:hover:before{transform:translate(100%)}.log-filter button.active[data-v-d38f4a7d]{background:var(--gradient-info);color:#fff;border-color:transparent;box-shadow:var(--shadow-sm);transform:translateY(-2px)}.log-filter button[data-v-d38f4a7d]:not(.active):hover{background-color:var(--color-background-mute);transform:translateY(-2px);box-shadow:var(--shadow-sm)}@media (max-width: 768px){.log-filter[data-v-d38f4a7d]{gap:6px;margin-bottom:12px}.log-filter button[data-v-d38f4a7d]{padding:6px 10px;font-size:12px;min-width:60px}}@media (max-width: 480px){.log-filter[data-v-d38f4a7d]{gap:4px;margin-bottom:10px}.log-filter button[data-v-d38f4a7d]{padding:5px 8px;font-size:11px;min-width:50px}}.log-container[data-v-d38f4a7d]{background-color:var(--log-entry-bg);border:1px solid var(--log-entry-border);border-radius:var(--radius-lg);padding:15px;margin-top:20px;max-height:500px;overflow-y:auto;font-family:JetBrains Mono,Fira Code,monospace;font-size:14px;line-height:1.5;transition:all .3s ease;box-shadow:var(--shadow-sm);position:relative}.log-container[data-v-d38f4a7d]::-webkit-scrollbar{width:8px}.log-container[data-v-d38f4a7d]::-webkit-scrollbar-track{background:var(--color-background-mute);border-radius:4px}.log-container[data-v-d38f4a7d]::-webkit-scrollbar-thumb{background:var(--button-primary);border-radius:4px;opacity:.7}.log-container[data-v-d38f4a7d]::-webkit-scrollbar-thumb:hover{background:var(--button-primary-hover)}.log-entry[data-v-d38f4a7d]{margin-bottom:8px;padding:10px;border-radius:var(--radius-md);word-break:break-word;transition:all .3s ease;position:relative;overflow:hidden;border-left:4px solid transparent;animation:logEntryAppear-d38f4a7d .3s ease forwards;opacity:0;transform:translateY(10px)}@keyframes logEntryAppear-d38f4a7d{0%{opacity:0;transform:translateY(10px)}to{opacity:1;transform:translateY(0)}}.log-entry[data-v-d38f4a7d]:after{content:"";position:absolute;top:0;left:0;width:100%;height:100%;background:linear-gradient(90deg,transparent,rgba(255,255,255,.05),transparent);transform:translate(-100%);transition:transform .6s ease}.log-entry[data-v-d38f4a7d]:hover:after{transform:translate(100%)}.log-entry.INFO[data-v-d38f4a7d]{background-color:#3b82f61a;border-left:4px solid #3b82f6}.log-entry.WARNING[data-v-d38f4a7d]{background-color:#f59e0b1a;border-left:4px solid #f59e0b}.log-entry.ERROR[data-v-d38f4a7d]{background-color:#ef44441a;border-left:4px solid #ef4444}.log-entry.DEBUG[data-v-d38f4a7d]{background-color:#10b9811a;border-left:4px solid #10b981}.log-timestamp[data-v-d38f4a7d]{color:var(--color-text);font-size:12px;margin-right:10px;opacity:.8;transition:all .3s ease;font-weight:500}.log-level[data-v-d38f4a7d]{font-weight:700;margin-right:10px;padding:2px 6px;border-radius:var(--radius-sm);font-size:12px;text-transform:uppercase;letter-spacing:.5px}.log-level.INFO[data-v-d38f4a7d]{color:#3b82f6;background-color:#3b82f61a}.log-level.WARNING[data-v-d38f4a7d]{color:#f59e0b;background-color:#f59e0b1a}.log-level.ERROR[data-v-d38f4a7d]{color:#ef4444;background-color:#ef44441a}.log-level.DEBUG[data-v-d38f4a7d]{color:#10b981;background-color:#10b9811a}.log-message[data-v-d38f4a7d]{color:var(--color-text);transition:all .3s ease;line-height:1.6}.log-entry[data-v-d38f4a7d]:hover{transform:translate(5px);box-shadow:var(--shadow-sm)}.log-entry:hover .log-timestamp[data-v-d38f4a7d]{opacity:1;color:var(--button-primary)}.log-entry:hover .log-message[data-v-d38f4a7d]{color:var(--color-heading)}@media (max-width: 768px){.log-container[data-v-d38f4a7d]{padding:12px;font-size:13px;max-height:400px}.log-entry[data-v-d38f4a7d]{padding:8px;margin-bottom:6px}.log-timestamp[data-v-d38f4a7d]{font-size:11px;display:block;margin-bottom:3px}.log-level[data-v-d38f4a7d]{font-size:11px;padding:1px 4px}}@media (max-width: 480px){.log-container[data-v-d38f4a7d]{padding:10px;font-size:12px;max-height:350px}.log-entry[data-v-d38f4a7d]{padding:6px;margin-bottom:5px}.log-timestamp[data-v-d38f4a7d]{font-size:10px}.log-level[data-v-d38f4a7d]{font-size:10px;padding:1px 3px;margin-right:5px}.log-message[data-v-d38f4a7d]{font-size:11px}}body{font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,sans-serif;line-height:1.6;background-color:var(--color-background);color:var(--color-text);margin:0;padding:0;transition:background-color .3s,color .3s}.dashboard{max-width:1200px;margin:0 auto;padding:20px;opacity:0;transform:translateY(20px);transition:opacity .5s cubic-bezier(.25,.46,.45,.94),transform .5s cubic-bezier(.25,.46,.45,.94)}.dashboard.page-loaded{opacity:1;transform:translateY(0)}.header-container{display:flex;justify-content:space-between;align-items:center;margin-bottom:20px;opacity:0;transform:translateY(20px) scale(.95);transition:opacity .4s cubic-bezier(.34,1.56,.64,1),transform .4s cubic-bezier(.34,1.56,.64,1);background:var(--gradient-primary);padding:20px;border-radius:var(--radius-xl);box-shadow:var(--shadow-lg);position:relative;overflow:hidden}.header-container:before{content:"";position:absolute;top:0;left:0;right:0;bottom:0;background:radial-gradient(circle at top right,rgba(255,255,255,.1),transparent 70%);z-index:0}.header-container:after{content:"";position:absolute;top:0;left:0;right:0;bottom:0;background:radial-gradient(circle at bottom left,rgba(255,255,255,.1),transparent 70%);z-index:0}.header-container.animate-in{opacity:1;transform:translateY(0) scale(1)}.title-container{display:flex;align-items:center;gap:15px;flex-wrap:wrap;position:relative;z-index:1}.toggle-container{display:flex;align-items:center;gap:15px;position:relative;z-index:1}.vertex-button,.theme-button{display:flex;align-items:center;justify-content:center;background-color:#ffffff26;padding:8px 16px;border-radius:var(--radius-full);border:1px solid rgba(255,255,255,.2);transition:all .3s ease;box-shadow:var(--shadow-md);cursor:pointer;font-size:.9rem;color:#fff;font-weight:500;min-width:90px;-webkit-backdrop-filter:blur(5px);backdrop-filter:blur(5px)}.vertex-button.active,.theme-button.active{background-color:#ffffff40;border-color:#ffffff4d;color:#fff}.vertex-button:hover,.theme-button:hover{background-color:#ffffff4d;transform:translateY(-2px);box-shadow:var(--shadow-lg)}h1{color:#fff;margin:0;font-size:1.8rem;text-shadow:0 2px 4px rgba(0,0,0,.2)}.vertex-toggle,.theme-toggle,.switch,.slider{display:none}.sections-row{display:flex;flex-direction:column;gap:20px;margin-bottom:20px}.status-section,.config-section{width:100%}@media (max-width: 768px){.dashboard{padding:10px 8px}.header-container{flex-direction:row;align-items:center;margin-bottom:15px;padding:15px}.title-container{width:auto;justify-content:flex-start;margin-bottom:0;flex:1}h1{font-size:1.4rem;text-align:left;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}h1:before{content:"🤖Gemini代理"}h1 span{display:none}.toggle-container{width:auto;justify-content:flex-end;flex-direction:row;gap:8px;margin-top:0;align-self:center}.vertex-button,.theme-button{padding:6px 12px;font-size:.8rem;min-width:80px}.sections-row{flex-direction:column;gap:15px}}@media (max-width: 480px){.dashboard{padding:6px 4px}.header-container{flex-direction:row;align-items:center;margin-bottom:15px;padding:12px}.title-container{width:auto;justify-content:flex-start;margin-bottom:0;flex:1}h1{font-size:1.1rem;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.toggle-container{width:auto;justify-content:flex-end;flex-direction:row;gap:4px;margin-top:0;align-self:center}.vertex-button,.theme-button{padding:4px 8px;font-size:.65rem;min-width:70px}.sections-row{flex-direction:column;gap:10px}}.refresh-button{display:block;margin:20px auto;padding:12px 24px;background:var(--gradient-secondary);color:#fff;border:none;border-radius:var(--radius-lg);font-size:16px;cursor:pointer;transition:all .3s ease;opacity:0;transform:translateY(20px) scale(.95);box-shadow:var(--shadow-md);position:relative;overflow:hidden}.refresh-button:before{content:"";position:absolute;top:0;left:0;right:0;bottom:0;background:linear-gradient(to right,transparent,rgba(255,255,255,.2),transparent);transform:translate(-100%);transition:transform .6s ease}.refresh-button:hover:before{transform:translate(100%)}.refresh-button.animate-in{opacity:1;transform:translateY(0) scale(1)}.refresh-button:hover{transform:translateY(-3px);box-shadow:var(--shadow-lg)}@media (max-width: 768px){:deep(.info-box){padding:15px 10px;margin-bottom:15px;border-radius:var(--radius-lg);background-color:var(--card-background);border:1px solid var(--card-border);box-shadow:var(--shadow-md);position:relative;overflow:hidden}:deep(.info-box):before{content:"";position:absolute;top:0;left:0;width:4px;height:100%;background:var(--gradient-primary)}:deep(.section-title){font-size:1.1rem;margin-bottom:15px;padding-bottom:8px;color:var(--color-heading);border-bottom:1px solid var(--color-border);position:relative}:deep(.section-title):after{content:"";position:absolute;bottom:-1px;left:0;width:50px;height:2px;background:var(--gradient-primary)}:deep(.stats-grid){gap:10px;margin-top:15px;margin-bottom:20px}.refresh-button{margin:20px auto;padding:10px 20px;font-size:14px}}@media (max-width: 480px){:deep(.info-box){padding:12px 8px;margin-bottom:10px;border-radius:var(--radius-md)}:deep(.section-title){font-size:1rem;margin-bottom:10px;padding-bottom:6px}:deep(.stats-grid){gap:8px;margin-top:10px;margin-bottom:15px}.refresh-button{margin:15px auto;padding:8px 16px;font-size:13px}}.section-animate{opacity:0;transform:translateY(20px) scale(.95);transition:opacity .4s cubic-bezier(.34,1.56,.64,1),transform .4s cubic-bezier(.34,1.56,.64,1)}.section-animate.animate-in{opacity:1;transform:translateY(0) scale(1)}:deep(.stats-grid){opacity:0;transform:translateY(10px) scale(.98);transition:opacity .3s cubic-bezier(.34,1.56,.64,1),transform .3s cubic-bezier(.34,1.56,.64,1)}.animate-in :deep(.stats-grid){opacity:1;transform:translateY(0) scale(1);transition-delay:.1s}:deep(.stat-card){opacity:0;transform:scale(.9) translateY(10px);transition:opacity .3s cubic-bezier(.34,1.56,.64,1),transform .3s cubic-bezier(.34,1.56,.64,1),box-shadow .3s,background-color .3s;position:relative;overflow:hidden}:deep(.stat-card):before{content:"";position:absolute;top:0;left:0;width:100%;height:4px;background:var(--gradient-secondary);opacity:0;transition:opacity .3s ease}:deep(.stat-card:hover):before{opacity:1}.animate-in :deep(.stat-card){opacity:1;transform:scale(1) translateY(0)}.animate-in :deep(.stat-card:nth-child(1)){transition-delay:.15s}.animate-in :deep(.stat-card:nth-child(2)){transition-delay:.2s}.animate-in :deep(.stat-card:nth-child(3)){transition-delay:.25s}.animate-in :deep(.stat-card:nth-child(4)){transition-delay:.3s}.animate-in :deep(.stat-card:nth-child(5)){transition-delay:.35s}.animate-in :deep(.stat-card:nth-child(6)){transition-delay:.4s}.animate-in :deep(.stat-card:nth-child(7)){transition-delay:.45s}.animate-in :deep(.stat-card:nth-child(8)){transition-delay:.5s}:deep(.log-entry){opacity:0;transform:translate(-10px) scale(.98);transition:opacity .3s cubic-bezier(.34,1.56,.64,1),transform .3s cubic-bezier(.34,1.56,.64,1);position:relative;overflow:hidden}:deep(.log-entry):after{content:"";position:absolute;top:0;left:0;width:100%;height:100%;background:linear-gradient(90deg,transparent,rgba(255,255,255,.05),transparent);transform:translate(-100%);transition:transform .6s ease}:deep(.log-entry:hover):after{transform:translate(100%)}.animate-in :deep(.log-entry){opacity:1;transform:translate(0) scale(1)}.animate-in :deep(.log-entry:nth-child(1)){transition-delay:.15s}.animate-in :deep(.log-entry:nth-child(2)){transition-delay:.2s}.animate-in :deep(.log-entry:nth-child(3)){transition-delay:.25s}.animate-in :deep(.log-entry:nth-child(4)){transition-delay:.3s}.animate-in :deep(.log-entry:nth-child(5)){transition-delay:.35s}.animate-in :deep(.log-entry:nth-child(n+6)){transition-delay:.4s}@keyframes flyIn{0%{opacity:0;transform:translateY(30px) scale(.9)}50%{opacity:.5;transform:translateY(15px) scale(.95)}to{opacity:1;transform:translateY(0) scale(1)}}@keyframes flyInFromLeft{0%{opacity:0;transform:translate(-20px) scale(.9)}50%{opacity:.5;transform:translate(-10px) scale(.95)}to{opacity:1;transform:translate(0) scale(1)}}@keyframes flyInFromRight{0%{opacity:0;transform:translate(20px) scale(.9)}50%{opacity:.5;transform:translate(10px) scale(.95)}to{opacity:1;transform:translate(0) scale(1)}}.header-container.animate-in,.section-animate.animate-in{animation:flyIn .5s cubic-bezier(.34,1.56,.64,1) forwards}.animate-in :deep(.stat-card:nth-child(odd)){animation:flyInFromLeft .4s cubic-bezier(.34,1.56,.64,1) forwards}.animate-in :deep(.stat-card:nth-child(even)){animation:flyInFromRight .4s cubic-bezier(.34,1.56,.64,1) forwards}.animate-in :deep(.log-entry){animation:flyInFromLeft .3s cubic-bezier(.34,1.56,.64,1) forwards}.refresh-button.animate-in{animation:flyIn .5s cubic-bezier(.34,1.56,.64,1) forwards}.password-dialog{position:fixed;top:0;left:0;right:0;bottom:0;background-color:#00000080;display:flex;align-items:flex-start;justify-content:center;z-index:1000;padding-top:100px;-webkit-backdrop-filter:blur(5px);backdrop-filter:blur(5px)}.password-dialog-content{background-color:var(--card-background);border-radius:var(--radius-xl);padding:25px;width:90%;max-width:400px;box-shadow:var(--shadow-xl);position:relative;overflow:hidden}.password-dialog-content:before{content:"";position:absolute;top:0;left:0;width:100%;height:5px;background:var(--gradient-primary)}.password-dialog-content h3{margin-top:0;margin-bottom:10px;color:var(--color-heading);font-size:1.3rem}.password-dialog-content p{margin-bottom:15px;color:var(--color-text);font-size:14px}.password-input-container{margin-bottom:20px;position:relative}.password-input{width:100%;padding:12px 16px;border:1px solid var(--color-border);border-radius:var(--radius-md);background-color:var(--color-background);color:var(--color-text);font-size:14px;transition:all .3s ease}.password-input:focus{outline:none;border-color:var(--button-primary);box-shadow:0 0 0 3px #4f46e533}.password-error{color:#ef4444;font-size:12px;margin-top:8px;padding-left:5px}.password-actions{display:flex;justify-content:flex-end;gap:10px}.cancel-btn,.confirm-btn{padding:10px 18px;border-radius:var(--radius-md);font-size:14px;cursor:pointer;transition:all .2s}.cancel-btn{background-color:var(--button-secondary);border:1px solid var(--color-border);color:var(--button-secondary-text)}.confirm-btn{background:var(--gradient-primary);border:none;color:#fff;box-shadow:var(--shadow-sm)}.cancel-btn:hover{background-color:var(--button-secondary-hover);transform:translateY(-2px)}.confirm-btn:hover{transform:translateY(-2px);box-shadow:var(--shadow-md)}@media (max-width: 768px){.password-dialog{padding-top:80px}.password-dialog-content{padding:20px}}@media (max-width: 480px){.password-dialog{padding-top:60px}.password-dialog-content{padding:15px}.password-dialog-content h3{font-size:1.1rem}.password-dialog-content p{font-size:12px}.password-input{font-size:12px;padding:10px 14px}.cancel-btn,.confirm-btn{padding:8px 14px;font-size:12px}}
|
app/templates/assets/aafbaf642c01961ff24ddb8941d1bf59.html
ADDED
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
<!DOCTYPE html>
|
2 |
+
<html lang="">
|
3 |
+
<head>
|
4 |
+
<meta charset="UTF-8">
|
5 |
+
<link rel="icon" href="/favicon.ico">
|
6 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
7 |
+
<title>HAJIMI</title>
|
8 |
+
<script type="module" crossorigin src="/main.js"></script>
|
9 |
+
<link rel="stylesheet" crossorigin href="/index.css">
|
10 |
+
</head>
|
11 |
+
<body>
|
12 |
+
<div id="app"></div>
|
13 |
+
</body>
|
14 |
+
</html>
|
app/templates/assets/favicon.ico
ADDED
|
app/templates/index.html
ADDED
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
|
2 |
+
<!DOCTYPE html>
|
3 |
+
<html lang="zh-CN">
|
4 |
+
<head>
|
5 |
+
<meta charset="UTF-8">
|
6 |
+
<link rel="icon" href="/assets/favicon.ico">
|
7 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
8 |
+
<title>Gemini API 代理服务</title>
|
9 |
+
<script type="module" crossorigin src="/assets/0506c607efda914c9388132c9cbb0c53.js"></script>
|
10 |
+
<link rel="stylesheet" href="/assets/9a4f356975f1a7b8b7bad9e93c1becba.css">
|
11 |
+
</head>
|
12 |
+
<body>
|
13 |
+
<div id="app"></div>
|
14 |
+
</body>
|
15 |
+
</html>
|
app/utils/__init__.py
ADDED
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# Utils package initialization
|
2 |
+
|
3 |
+
from app.utils.logging import logger, log_manager, format_log_message,log
|
4 |
+
from app.utils.api_key import APIKeyManager, test_api_key
|
5 |
+
from app.utils.error_handling import handle_gemini_error, translate_error, handle_api_error
|
6 |
+
from app.utils.rate_limiting import protect_from_abuse
|
7 |
+
from app.utils.cache import ResponseCacheManager, generate_cache_key
|
8 |
+
from app.utils.request import ActiveRequestsManager
|
9 |
+
from app.utils.stats import clean_expired_stats, update_api_call_stats
|
10 |
+
from app.utils.version import check_version
|
11 |
+
from app.utils.maintenance import handle_exception, schedule_cache_cleanup
|
12 |
+
from app.utils.response import openAI_from_text
|
app/utils/api_key.py
ADDED
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import random
|
2 |
+
import re
|
3 |
+
import os
|
4 |
+
import logging
|
5 |
+
import asyncio
|
6 |
+
from datetime import datetime, timedelta
|
7 |
+
from apscheduler.schedulers.background import BackgroundScheduler
|
8 |
+
from app.utils.logging import format_log_message
|
9 |
+
import app.config.settings as settings
|
10 |
+
logger = logging.getLogger("my_logger")
|
11 |
+
|
12 |
+
class APIKeyManager:
|
13 |
+
def __init__(self):
|
14 |
+
self.api_keys = re.findall(
|
15 |
+
r"AIzaSy[a-zA-Z0-9_-]{33}", settings.GEMINI_API_KEYS)
|
16 |
+
# 加载更多 GEMINI_API_KEYS
|
17 |
+
for i in range(1, 99):
|
18 |
+
if keys := os.environ.get(f"GEMINI_API_KEYS_{i}", ""):
|
19 |
+
self.api_keys += re.findall(r"AIzaSy[a-zA-Z0-9_-]{33}", keys)
|
20 |
+
else:
|
21 |
+
break
|
22 |
+
|
23 |
+
self.key_stack = [] # 初始化密钥栈
|
24 |
+
self._reset_key_stack() # 初始化时创建随机密钥栈
|
25 |
+
self.scheduler = BackgroundScheduler()
|
26 |
+
self.scheduler.start()
|
27 |
+
self.lock = asyncio.Lock() # Added lock
|
28 |
+
|
29 |
+
def _reset_key_stack(self):
|
30 |
+
"""创建并随机化密钥栈"""
|
31 |
+
shuffled_keys = self.api_keys[:] # 创建 api_keys 的副本以避免直接修改原列表
|
32 |
+
random.shuffle(shuffled_keys)
|
33 |
+
self.key_stack = shuffled_keys
|
34 |
+
|
35 |
+
async def get_available_key(self):
|
36 |
+
"""从栈顶获取密钥,若栈空则重新生成
|
37 |
+
|
38 |
+
实现负载均衡:
|
39 |
+
1. 维护一个随机排序的栈存储apikey
|
40 |
+
2. 每次调用从栈顶取出一个key返回
|
41 |
+
3. 栈空时重新随机生成栈
|
42 |
+
4. 确保异步和并发安全
|
43 |
+
"""
|
44 |
+
async with self.lock:
|
45 |
+
# 如果栈为空,重新生成
|
46 |
+
if not self.key_stack:
|
47 |
+
self._reset_key_stack()
|
48 |
+
|
49 |
+
# 从栈顶取出key
|
50 |
+
if self.key_stack:
|
51 |
+
return self.key_stack.pop()
|
52 |
+
|
53 |
+
# 如果没有可用的API密钥,记录错误
|
54 |
+
if not self.api_keys:
|
55 |
+
log_msg = format_log_message('ERROR', "没有配置任何 API 密钥!")
|
56 |
+
logger.error(log_msg)
|
57 |
+
log_msg = format_log_message('ERROR', "没有可用的API密钥!")
|
58 |
+
logger.error(log_msg)
|
59 |
+
return None
|
60 |
+
|
61 |
+
def show_all_keys(self):
|
62 |
+
log_msg = format_log_message('INFO', f"当前可用API key个数: {len(self.api_keys)} ")
|
63 |
+
logger.info(log_msg)
|
64 |
+
for i, api_key in enumerate(self.api_keys):
|
65 |
+
log_msg = format_log_message('INFO', f"API Key{i}: {api_key[:8]}...{api_key[-3:]}")
|
66 |
+
logger.info(log_msg)
|
67 |
+
|
68 |
+
# def blacklist_key(self, key):
|
69 |
+
# log_msg = format_log_message('WARNING', f"{key[:8]} → 暂时禁用 {self.api_key_blacklist_duration} 秒")
|
70 |
+
# logger.warning(log_msg)
|
71 |
+
# self.api_key_blacklist.add(key)
|
72 |
+
# self.scheduler.add_job(lambda: self.api_key_blacklist.discard(key), 'date',
|
73 |
+
# run_date=datetime.now() + timedelta(seconds=self.api_key_blacklist_duration))
|
74 |
+
|
75 |
+
async def test_api_key(api_key: str) -> bool:
|
76 |
+
"""
|
77 |
+
测试 API 密钥是否有效。
|
78 |
+
"""
|
79 |
+
try:
|
80 |
+
import httpx
|
81 |
+
url = "https://generativelanguage.googleapis.com/v1beta/models?key={}".format(api_key)
|
82 |
+
async with httpx.AsyncClient() as client:
|
83 |
+
response = await client.get(url)
|
84 |
+
response.raise_for_status()
|
85 |
+
return True
|
86 |
+
except Exception:
|
87 |
+
return False
|
app/utils/auth.py
ADDED
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from typing import Optional
|
2 |
+
from fastapi import HTTPException, Header, Query
|
3 |
+
import app.config.settings as settings
|
4 |
+
|
5 |
+
# 自定义密码校验依赖函数
|
6 |
+
async def custom_verify_password(
|
7 |
+
authorization: Optional[str] = Header(None, description="OpenAI 格式请求 Key, 格式: Bearer sk-xxxx"),
|
8 |
+
x_goog_api_key: Optional[str] = Header(None, description="Gemini 格式请求 Key, 从请求头 x-goog-api-key 获取"),
|
9 |
+
key: Optional[str] = Query(None, description="Gemini 格式请求 Key, 从查询参数 key 获取"),
|
10 |
+
alt: Optional[str] = None
|
11 |
+
):
|
12 |
+
"""
|
13 |
+
@@ -15,22 +18,79 @@
|
14 |
+
2. 根据类型,与项目配置的密钥进行比对。
|
15 |
+
3. 如果 Key 无效、缺失或不匹配,则抛出 HTTPException。
|
16 |
+
"""
|
17 |
+
client_provided_api_key: Optional[str] = None
|
18 |
+
|
19 |
+
# 提取客户端提供的 Key
|
20 |
+
if x_goog_api_key:
|
21 |
+
client_provided_api_key = x_goog_api_key
|
22 |
+
elif key:
|
23 |
+
client_provided_api_key = key
|
24 |
+
elif authorization and authorization.startswith("Bearer "):
|
25 |
+
token = authorization.split(" ", 1)[1]
|
26 |
+
client_provided_api_key = token
|
27 |
+
|
28 |
+
# 进行校验和比对
|
29 |
+
if (not client_provided_api_key) or (client_provided_api_key != settings.PASSWORD) :
|
30 |
+
raise HTTPException(
|
31 |
+
status_code=401, detail="Unauthorized: Invalid token")
|
32 |
+
|
33 |
+
def verify_web_password(password:str):
|
34 |
+
if password != settings.WEB_PASSWORD:
|
35 |
+
return False
|
36 |
+
return True
|
app/utils/cache.py
ADDED
@@ -0,0 +1,291 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import time
|
2 |
+
import xxhash
|
3 |
+
import asyncio
|
4 |
+
from typing import Dict, Any, Optional, Tuple
|
5 |
+
import logging
|
6 |
+
from collections import deque
|
7 |
+
from app.utils.logging import log
|
8 |
+
logger = logging.getLogger("my_logger")
|
9 |
+
import heapq
|
10 |
+
|
11 |
+
# 定义缓存项的结构
|
12 |
+
CacheItem = Dict[str, Any]
|
13 |
+
|
14 |
+
class ResponseCacheManager:
|
15 |
+
"""管理API响应缓存的类,一个键可以对应多个缓存项(使用deque)"""
|
16 |
+
|
17 |
+
def __init__(self, expiry_time: int, max_entries: int,
|
18 |
+
cache_dict: Dict[str, deque[CacheItem]] = None):
|
19 |
+
"""
|
20 |
+
初始化缓存管理器。
|
21 |
+
|
22 |
+
Args:
|
23 |
+
expiry_time (int): 缓存项的过期时间(秒)。
|
24 |
+
max_entries (int): 缓存中允许的最大总条目数。
|
25 |
+
cache_dict (Dict[str, deque[CacheItem]], optional): 初始缓存字典。默认为 None。
|
26 |
+
"""
|
27 |
+
self.cache: Dict[str, deque[CacheItem]] = cache_dict if cache_dict is not None else {}
|
28 |
+
self.expiry_time = expiry_time
|
29 |
+
self.max_entries = max_entries # 总条目数限制
|
30 |
+
self.cur_cache_num = 0 # 当前条目数
|
31 |
+
self.lock = asyncio.Lock() # Added lock
|
32 |
+
|
33 |
+
async def get(self, cache_key: str) -> Tuple[Optional[Any], bool]: # Made async
|
34 |
+
"""获取指定键的第一个有效缓存项(不删除)"""
|
35 |
+
now = time.time()
|
36 |
+
async with self.lock:
|
37 |
+
if cache_key in self.cache:
|
38 |
+
cache_deque = self.cache[cache_key]
|
39 |
+
# 查找第一个未过期的项,且不删除
|
40 |
+
for item in cache_deque:
|
41 |
+
if now < item.get('expiry_time', 0):
|
42 |
+
response = item.get('response',None)
|
43 |
+
return response, True
|
44 |
+
|
45 |
+
return None, False
|
46 |
+
|
47 |
+
async def get_and_remove(self, cache_key: str) -> Tuple[Optional[Any], bool]:
|
48 |
+
"""获取并删除指定键的第一个有效缓存项。"""
|
49 |
+
now = time.time()
|
50 |
+
async with self.lock:
|
51 |
+
if cache_key in self.cache:
|
52 |
+
cache_deque = self.cache[cache_key]
|
53 |
+
|
54 |
+
# 查找第一个有效项并收集过期项
|
55 |
+
valid_item_to_remove = None
|
56 |
+
response_to_return = None
|
57 |
+
new_deque = deque()
|
58 |
+
items_removed_count = 0
|
59 |
+
|
60 |
+
for item in cache_deque:
|
61 |
+
if now < item.get('expiry_time', 0):
|
62 |
+
if valid_item_to_remove is None: # 找到第一个有效项
|
63 |
+
valid_item_to_remove = item
|
64 |
+
response_to_return = item.get('response', None)
|
65 |
+
items_removed_count += 1 # 计数此项为移除
|
66 |
+
|
67 |
+
else:
|
68 |
+
new_deque.append(item) # 保留后续有效项
|
69 |
+
else:
|
70 |
+
items_removed_count += 1 # 计数过期项为移除
|
71 |
+
|
72 |
+
# 更新缓存状态
|
73 |
+
if items_removed_count > 0:
|
74 |
+
self.cur_cache_num = max(0, self.cur_cache_num - items_removed_count)
|
75 |
+
if not new_deque:
|
76 |
+
# 如果所有项都被移除(过期或我们取的那个)
|
77 |
+
del self.cache[cache_key]
|
78 |
+
else:
|
79 |
+
self.cache[cache_key] = new_deque
|
80 |
+
|
81 |
+
if valid_item_to_remove:
|
82 |
+
return response_to_return, True # 返回找到的有效项
|
83 |
+
|
84 |
+
# 如果键不存在或未找到有效项
|
85 |
+
return None, False
|
86 |
+
|
87 |
+
async def store(self, cache_key: str, response: Any):
|
88 |
+
"""存储响应到缓存(追加到键对应的deque)"""
|
89 |
+
now = time.time()
|
90 |
+
new_item: CacheItem = {
|
91 |
+
'response': response,
|
92 |
+
'expiry_time': now + self.expiry_time,
|
93 |
+
'created_at': now,
|
94 |
+
}
|
95 |
+
|
96 |
+
needs_cleaning = False
|
97 |
+
async with self.lock:
|
98 |
+
if cache_key not in self.cache:
|
99 |
+
self.cache[cache_key] = deque()
|
100 |
+
|
101 |
+
self.cache[cache_key].append(new_item) # 追加到deque末尾
|
102 |
+
self.cur_cache_num += 1
|
103 |
+
needs_cleaning = self.cur_cache_num > self.max_entries
|
104 |
+
|
105 |
+
if needs_cleaning:
|
106 |
+
# 在锁外调用清理,避免长时间持有锁
|
107 |
+
await self.clean_if_needed()
|
108 |
+
|
109 |
+
async def clean_expired(self):
|
110 |
+
"""清理所有缓存项中已过期的项。"""
|
111 |
+
now = time.time()
|
112 |
+
keys_to_remove = []
|
113 |
+
total_cleaned = 0
|
114 |
+
async with self.lock:
|
115 |
+
# 迭代 cache 的副本以允许在循环中安全地修改 cache
|
116 |
+
for key, cache_deque in list(self.cache.items()):
|
117 |
+
original_len = len(cache_deque)
|
118 |
+
# 创建一个新的 deque,只包含未过期的项
|
119 |
+
valid_items = deque(item for item in cache_deque if now < item.get('expiry_time', 0))
|
120 |
+
cleaned_count = original_len - len(valid_items)
|
121 |
+
|
122 |
+
if cleaned_count > 0:
|
123 |
+
log('info', f"清理键 {key[:8]}... 的过期缓存项 {cleaned_count} 个。")
|
124 |
+
total_cleaned += cleaned_count
|
125 |
+
|
126 |
+
if not valid_items:
|
127 |
+
keys_to_remove.append(key) # 标记此键以便稍后删除
|
128 |
+
# 在持有锁时直接删除键
|
129 |
+
if key in self.cache:
|
130 |
+
del self.cache[key]
|
131 |
+
log('info', f"缓存键 {key[:8]}... 的所有项均已过期,移除该键。")
|
132 |
+
elif cleaned_count > 0:
|
133 |
+
# 替换为只包含有效项的 deque
|
134 |
+
self.cache[key] = valid_items
|
135 |
+
|
136 |
+
# 统一更新缓存计数
|
137 |
+
if total_cleaned > 0:
|
138 |
+
self.cur_cache_num = max(0, self.cur_cache_num - total_cleaned)
|
139 |
+
|
140 |
+
async def clean_if_needed(self):
|
141 |
+
"""如果缓存总条目数超过限制,清理全局最旧的项目。"""
|
142 |
+
|
143 |
+
async with self.lock:
|
144 |
+
if self.cur_cache_num <= self.max_entries:
|
145 |
+
return
|
146 |
+
|
147 |
+
# 计算目标大小和需要移除的数量
|
148 |
+
target_size = max(self.max_entries - 10, 10)
|
149 |
+
if self.cur_cache_num <= target_size:
|
150 |
+
return
|
151 |
+
|
152 |
+
items_to_remove_count = self.cur_cache_num - target_size
|
153 |
+
log('info', f"缓存总数 {self.cur_cache_num} 超过限制 {self.max_entries},需要清理 {items_to_remove_count} 个")
|
154 |
+
|
155 |
+
# 收集所有缓存项及其元数据
|
156 |
+
all_items_meta = []
|
157 |
+
for key, cache_deque in self.cache.items():
|
158 |
+
for item in cache_deque:
|
159 |
+
all_items_meta.append({'key': key, 'created_at': item.get('created_at', 0), 'item': item})
|
160 |
+
|
161 |
+
# 找出最旧的 N 项
|
162 |
+
actual_remove_count = min(items_to_remove_count, len(all_items_meta))
|
163 |
+
if actual_remove_count <= 0:
|
164 |
+
return # 没有项目可移除或无需移除
|
165 |
+
|
166 |
+
items_to_remove = heapq.nsmallest(actual_remove_count, all_items_meta, key=lambda x: x['created_at'])
|
167 |
+
|
168 |
+
# 执行移除
|
169 |
+
items_actually_removed = 0
|
170 |
+
keys_potentially_empty = set()
|
171 |
+
for item_meta in items_to_remove:
|
172 |
+
key_to_clean = item_meta['key']
|
173 |
+
item_to_clean = item_meta['item']
|
174 |
+
|
175 |
+
if key_to_clean in self.cache:
|
176 |
+
try:
|
177 |
+
# 直接从 deque 中移除指定的 item 对象
|
178 |
+
self.cache[key_to_clean].remove(item_to_clean)
|
179 |
+
items_actually_removed += 1
|
180 |
+
# 计数器在最后统一更新
|
181 |
+
log('info', f"因容量限制,删除键 {key_to_clean[:8]}... 的旧缓存项 (创建于 {item_meta['created_at']})。")
|
182 |
+
keys_potentially_empty.add(key_to_clean)
|
183 |
+
except (KeyError, ValueError):
|
184 |
+
log('warning', f"尝试因容量限制删除缓存项时未找到 (可能已被提前移除): {key_to_clean[:8]}...")
|
185 |
+
pass
|
186 |
+
|
187 |
+
# 检查是否有 deque 因本次清理变空
|
188 |
+
for key in keys_potentially_empty:
|
189 |
+
if key in self.cache and not self.cache[key]:
|
190 |
+
del self.cache[key]
|
191 |
+
log('info', f"因容量限制清理后,键 {key[:8]}... 的deque已空,移除该键。")
|
192 |
+
|
193 |
+
# 统一更新缓存计数
|
194 |
+
if items_actually_removed > 0:
|
195 |
+
self.cur_cache_num = max(0, self.cur_cache_num - items_actually_removed)
|
196 |
+
log('info', f"因容量限制,共清理了 {items_actually_removed} 个旧缓存项。清理后缓存数: {self.cur_cache_num}")
|
197 |
+
|
198 |
+
def generate_cache_key(chat_request, last_n_messages: int = 65536, is_gemini=False) -> str:
|
199 |
+
"""
|
200 |
+
根据模型名称和最后 N 条消息生成请求的唯一缓存键。
|
201 |
+
Args:
|
202 |
+
chat_request: 包含模型和消息列表的请求对象 (符合OpenAI格式)。
|
203 |
+
last_n_messages: 需要包含在缓存键计算中的最后消息的数量。
|
204 |
+
Returns:
|
205 |
+
一个代表该请求的唯一缓存键字符串 (xxhash64哈希值)。
|
206 |
+
"""
|
207 |
+
h = xxhash.xxh64()
|
208 |
+
|
209 |
+
# 1. 哈希模型名称
|
210 |
+
h.update(chat_request.model.encode('utf-8'))
|
211 |
+
|
212 |
+
if last_n_messages <= 0:
|
213 |
+
# 如果不考虑消息,直接返回基于模型的哈希
|
214 |
+
return h.hexdigest()
|
215 |
+
|
216 |
+
messages_processed = 0
|
217 |
+
|
218 |
+
# 2. 增量哈希最后 N 条消息 (从后往前)
|
219 |
+
if is_gemini:
|
220 |
+
# log('INFO', f"开启增量哈希gemini格式内容")
|
221 |
+
for content_item in reversed(chat_request.payload.contents):
|
222 |
+
if messages_processed >= last_n_messages:
|
223 |
+
break
|
224 |
+
role = content_item.get('role')
|
225 |
+
if role is not None and isinstance(role, str):
|
226 |
+
h.update(b'role:')
|
227 |
+
h.update(role.encode('utf-8'))
|
228 |
+
# log('INFO', f"哈希gemini格式角��{role}")
|
229 |
+
parts = content_item.get('parts', [])
|
230 |
+
if not isinstance(parts, list):
|
231 |
+
parts = []
|
232 |
+
for part in parts:
|
233 |
+
text_content = part.get('text')
|
234 |
+
if text_content is not None and isinstance(text_content, str):
|
235 |
+
h.update(b'text:')
|
236 |
+
h.update(text_content.encode('utf-8'))
|
237 |
+
# log('INFO', f"哈希gemini格式文本内容{text_content}")
|
238 |
+
|
239 |
+
inline_data_obj = part.get('inline_data')
|
240 |
+
if inline_data_obj is not None and isinstance(inline_data_obj, dict):
|
241 |
+
h.update(b'inline_data:')
|
242 |
+
data_payload = inline_data_obj.get('data', '')
|
243 |
+
# log('INFO', f"哈希gemini格式非文本内容{data_payload[:32]}")
|
244 |
+
if isinstance(data_payload, str):
|
245 |
+
h.update(b'data_prefix:')
|
246 |
+
h.update(data_payload[:32].encode('utf-8'))
|
247 |
+
|
248 |
+
file_data_obj = part.get('file_data')
|
249 |
+
if file_data_obj is not None and isinstance(file_data_obj, dict):
|
250 |
+
h.update(b'file_data:')
|
251 |
+
file_uri = file_data_obj.get('file_uri', '')
|
252 |
+
if isinstance(file_uri, str):
|
253 |
+
h.update(b'file_uri:')
|
254 |
+
h.update(file_uri.encode('utf-8'))
|
255 |
+
messages_processed += 1
|
256 |
+
|
257 |
+
else :
|
258 |
+
for msg in reversed(chat_request.messages):
|
259 |
+
if messages_processed >= last_n_messages:
|
260 |
+
break
|
261 |
+
|
262 |
+
# 哈希角色
|
263 |
+
h.update(b'role:')
|
264 |
+
h.update(msg.get('role', '').encode('utf-8'))
|
265 |
+
|
266 |
+
# 哈希内容
|
267 |
+
content = msg.get('content')
|
268 |
+
if isinstance(content, str):
|
269 |
+
h.update(b'text:')
|
270 |
+
h.update(content.encode('utf-8'))
|
271 |
+
elif isinstance(content, list):
|
272 |
+
# 处理图文混合内容
|
273 |
+
for item in content:
|
274 |
+
item_type = item.get('type') if hasattr(item, 'get') else None
|
275 |
+
if item_type == 'text':
|
276 |
+
text = item.get('text', '') if hasattr(item, 'get') else ''
|
277 |
+
h.update(b'text:')
|
278 |
+
h.update(text.encode('utf-8'))
|
279 |
+
elif item_type == 'image_url':
|
280 |
+
image_url = item.get('image_url', {}) if hasattr(item, 'get') else {}
|
281 |
+
image_data = image_url.get('url', '') if hasattr(image_url, 'get') else ''
|
282 |
+
|
283 |
+
h.update(b'image_url:') # 加入类型标识符
|
284 |
+
if image_data.startswith('data:image/'):
|
285 |
+
# 对于base64图像,使用前32字符作为标识符
|
286 |
+
h.update(image_data[:32].encode('utf-8'))
|
287 |
+
else:
|
288 |
+
h.update(image_data.encode('utf-8'))
|
289 |
+
|
290 |
+
messages_processed += 1
|
291 |
+
return h.hexdigest()
|
app/utils/error_handling.py
ADDED
@@ -0,0 +1,136 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import requests
|
2 |
+
import httpx # 添加 httpx 导入
|
3 |
+
import logging
|
4 |
+
import asyncio
|
5 |
+
from fastapi import HTTPException, status
|
6 |
+
from app.utils.logging import format_log_message
|
7 |
+
from app.utils.logging import log
|
8 |
+
|
9 |
+
logger = logging.getLogger("my_logger")
|
10 |
+
|
11 |
+
def handle_gemini_error(error, current_api_key) -> str:
|
12 |
+
# 同时检查 requests 和 httpx 的 HTTPError
|
13 |
+
if isinstance(error, (requests.exceptions.HTTPError, httpx.HTTPStatusError)):
|
14 |
+
status_code = error.response.status_code
|
15 |
+
if status_code == 400:
|
16 |
+
try:
|
17 |
+
error_data = error.response.json()
|
18 |
+
if 'error' in error_data:
|
19 |
+
if error_data['error'].get('code') == "invalid_argument":
|
20 |
+
error_message = "无效的 API 密钥"
|
21 |
+
log('ERROR', f"{current_api_key[:8]} ... {current_api_key[-3:]} → 无效,可能已过期或被删除",
|
22 |
+
extra={'key': current_api_key[:8], 'status_code': status_code, 'error_message': error_message})
|
23 |
+
# key_manager.blacklist_key(current_api_key)
|
24 |
+
|
25 |
+
return error_message
|
26 |
+
error_message = error_data['error'].get('message', 'Bad Request')
|
27 |
+
|
28 |
+
log('WARNING', f"400 错误请求: {error_message}",
|
29 |
+
extra={'key': current_api_key[:8], 'status_code': status_code, 'error_message': error_message})
|
30 |
+
return f"400 错误请求: {error_message}"
|
31 |
+
except ValueError:
|
32 |
+
error_message = "400 错误请求:响应不是有效的JSON格式"
|
33 |
+
extra_log_400_json = {'key': current_api_key[:8], 'status_code': status_code, 'error_message': error_message}
|
34 |
+
log('WARNING', error_message, extra=extra_log_400_json)
|
35 |
+
return error_message
|
36 |
+
|
37 |
+
elif status_code == 403:
|
38 |
+
error_message = f"权限被拒绝"
|
39 |
+
log('ERROR', error_message,
|
40 |
+
extra={'key': current_api_key[:8], 'status_code': status_code})
|
41 |
+
# key_manager.blacklist_key(current_api_key)
|
42 |
+
|
43 |
+
return error_message
|
44 |
+
|
45 |
+
elif status_code == 429:
|
46 |
+
error_message = f"API 密钥配额已用尽或其他原因"
|
47 |
+
log('WARNING', error_message,
|
48 |
+
extra={'key': current_api_key[:8], 'status_code': status_code})
|
49 |
+
# key_manager.blacklist_key(current_api_key)
|
50 |
+
|
51 |
+
return error_message
|
52 |
+
|
53 |
+
if status_code == 500:
|
54 |
+
error_message = f'Gemini API 内部错误'
|
55 |
+
log('WARNING', error_message,
|
56 |
+
extra={'key': current_api_key[:8], 'status_code': status_code})
|
57 |
+
return error_message
|
58 |
+
|
59 |
+
if status_code == 503:
|
60 |
+
error_message = f"Gemini API 服务繁忙"
|
61 |
+
log('WARNING', error_message,
|
62 |
+
extra={'key': current_api_key[:8], 'status_code': status_code})
|
63 |
+
return error_message
|
64 |
+
|
65 |
+
else:
|
66 |
+
error_message = f"未知错误: {status_code}"
|
67 |
+
log('WARNING', f"{status_code} 未知错误",
|
68 |
+
extra={'key': current_api_key[:8], 'status_code': status_code, 'error_message': error_message})
|
69 |
+
|
70 |
+
return f"未知错误/模型不可用: {status_code}"
|
71 |
+
|
72 |
+
elif isinstance(error, requests.exceptions.ConnectionError):
|
73 |
+
error_message = "连接错误"
|
74 |
+
log('WARNING', error_message, extra={'error_message': error_message})
|
75 |
+
return error_message
|
76 |
+
|
77 |
+
elif isinstance(error, requests.exceptions.Timeout):
|
78 |
+
error_message = "请求超时"
|
79 |
+
log('WARNING', error_message, extra={'error_message': error_message})
|
80 |
+
return error_message
|
81 |
+
else:
|
82 |
+
error_message = f"发生未知错误: {error}"
|
83 |
+
log('ERROR', error_message, extra={'error_message': error_message})
|
84 |
+
return error_message
|
85 |
+
|
86 |
+
def translate_error(message: str) -> str:
|
87 |
+
if "quota exceeded" in message.lower():
|
88 |
+
return "API 密钥配额已用尽"
|
89 |
+
if "invalid argument" in message.lower():
|
90 |
+
return "无效参数"
|
91 |
+
if "internal server error" in message.lower():
|
92 |
+
return "服务器内部错误"
|
93 |
+
if "service unavailable" in message.lower():
|
94 |
+
return "服务不可用"
|
95 |
+
return message
|
96 |
+
|
97 |
+
async def handle_api_error(e: Exception, api_key: str, key_manager, request_type: str, model: str, retry_count: int = 0):
|
98 |
+
"""统一处理API错误"""
|
99 |
+
|
100 |
+
# 同时检查 requests 和 httpx 的 HTTPError
|
101 |
+
if isinstance(e, (requests.exceptions.HTTPError, httpx.HTTPStatusError)):
|
102 |
+
status_code = e.response.status_code
|
103 |
+
# 对500和503错误实现自动重试机制, 最多重试3次
|
104 |
+
if retry_count < 3 and (status_code == 500 or status_code == 503):
|
105 |
+
error_message = 'Gemini API 内部错误' if (status_code == 500) else "Gemini API 服务目前不可用"
|
106 |
+
|
107 |
+
# 等待时间 : MIN_RETRY_DELAY=1, MAX_RETRY_DELAY=16
|
108 |
+
wait_time = min(1 * (2 ** retry_count), 16)
|
109 |
+
log('warning', f"{error_message},将等待{wait_time}秒后重试 ({retry_count+1}/3)",
|
110 |
+
extra={'key': api_key[:8], 'request_type': request_type, 'model': model, 'status_code': int(status_code)})
|
111 |
+
# 等待后返回重试信号
|
112 |
+
await asyncio.sleep(wait_time)
|
113 |
+
return {'remove_cache': False}
|
114 |
+
|
115 |
+
elif status_code == 429:
|
116 |
+
error_message = "API 密钥配额已用尽或其他原因"
|
117 |
+
log('WARNING', f"429 官方资源耗尽或其他原因",
|
118 |
+
extra={'key': api_key[:8], 'status_code': status_code, 'error_message': error_message})
|
119 |
+
# key_manager.blacklist_key(api_key)
|
120 |
+
|
121 |
+
return {'remove_cache': False,'error': error_message, 'should_switch_key': True}
|
122 |
+
|
123 |
+
else:
|
124 |
+
error_detail = handle_gemini_error(e, api_key)
|
125 |
+
|
126 |
+
# # 重试次数用尽,在日志中输出错误状态码
|
127 |
+
# log('error', f"Gemini 服务器错误({status_code})",
|
128 |
+
# extra={'key': api_key[:8], 'request_type': request_type, 'model': model, 'status_code': int(status_code)})
|
129 |
+
|
130 |
+
# 不再切换密钥,直接向客户端抛出HTTP异常
|
131 |
+
raise HTTPException(status_code=int(status_code),
|
132 |
+
detail=f"Gemini API 服务器错误({status_code}),请稍后重试")
|
133 |
+
|
134 |
+
# 对于其他错误,返回切换密钥的信号,并输出错误信息到日志中
|
135 |
+
error_detail = handle_gemini_error(e, api_key)
|
136 |
+
return {'should_switch_key': True, 'error': error_detail, 'remove_cache': True}
|
app/utils/logging.py
ADDED
@@ -0,0 +1,148 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import logging
|
2 |
+
from datetime import datetime
|
3 |
+
from collections import deque
|
4 |
+
from threading import Lock
|
5 |
+
|
6 |
+
DEBUG = False # 可以从环境变量中获取
|
7 |
+
|
8 |
+
LOG_FORMAT_DEBUG = '%(asctime)s - %(levelname)s - [%(key)s]-%(request_type)s-[%(model)s]-%(status_code)s: %(message)s - %(error_message)s'
|
9 |
+
LOG_FORMAT_NORMAL = '[%(asctime)s] [%(levelname)s] [%(key)s]-%(request_type)s-[%(model)s]-%(status_code)s: %(message)s'
|
10 |
+
|
11 |
+
# Vertex日志格式
|
12 |
+
VERTEX_LOG_FORMAT_DEBUG = '%(asctime)s - %(levelname)s - [%(vertex_id)s]-%(operation)s-[%(status)s]: %(message)s - %(error_message)s'
|
13 |
+
VERTEX_LOG_FORMAT_NORMAL = '[%(asctime)s] [%(levelname)s] [%(vertex_id)s]-%(operation)s-[%(status)s]: %(message)s'
|
14 |
+
|
15 |
+
# 配置 logger
|
16 |
+
logger = logging.getLogger("my_logger")
|
17 |
+
logger.setLevel(logging.DEBUG)
|
18 |
+
|
19 |
+
# 控制台处理器
|
20 |
+
console_handler = logging.StreamHandler()
|
21 |
+
|
22 |
+
# 设置日志格式
|
23 |
+
console_formatter = logging.Formatter('%(message)s')
|
24 |
+
console_handler.setFormatter(console_formatter)
|
25 |
+
logger.addHandler(console_handler)
|
26 |
+
|
27 |
+
# 日志缓存,用于在网页上显示最近的日志
|
28 |
+
class LogManager:
|
29 |
+
def __init__(self, max_logs=100):
|
30 |
+
self.logs = deque(maxlen=max_logs) # 使用双端队列存储最近的日志
|
31 |
+
self.lock = Lock()
|
32 |
+
|
33 |
+
def add_log(self, log_entry):
|
34 |
+
with self.lock:
|
35 |
+
self.logs.append(log_entry)
|
36 |
+
|
37 |
+
def get_recent_logs(self, count=50):
|
38 |
+
with self.lock:
|
39 |
+
return list(self.logs)[-count:]
|
40 |
+
|
41 |
+
# 创建日志管理器实例 (输出到前端)
|
42 |
+
log_manager = LogManager()
|
43 |
+
|
44 |
+
# Vertex日志缓存,用于在网页上显示最近的Vertex日志
|
45 |
+
class VertexLogManager:
|
46 |
+
def __init__(self, max_logs=100):
|
47 |
+
self.logs = deque(maxlen=max_logs) # 使用双端队列存储最近的Vertex日志
|
48 |
+
self.lock = Lock()
|
49 |
+
|
50 |
+
def add_log(self, log_entry):
|
51 |
+
with self.lock:
|
52 |
+
self.logs.append(log_entry)
|
53 |
+
|
54 |
+
def get_recent_logs(self, count=50):
|
55 |
+
with self.lock:
|
56 |
+
return list(self.logs)[-count:]
|
57 |
+
|
58 |
+
# 创建Vertex日志管理器实例 (输出到前端)
|
59 |
+
vertex_log_manager = VertexLogManager()
|
60 |
+
|
61 |
+
def format_log_message(level, message, extra=None):
|
62 |
+
extra = extra or {}
|
63 |
+
log_values = {
|
64 |
+
'asctime': datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
65 |
+
'levelname': level,
|
66 |
+
'key': extra.get('key', ''),
|
67 |
+
'request_type': extra.get('request_type', ''),
|
68 |
+
'model': extra.get('model', ''),
|
69 |
+
'status_code': extra.get('status_code', ''),
|
70 |
+
'error_message': extra.get('error_message', ''),
|
71 |
+
'message': message
|
72 |
+
}
|
73 |
+
log_format = LOG_FORMAT_DEBUG if DEBUG else LOG_FORMAT_NORMAL
|
74 |
+
formatted_log = log_format % log_values
|
75 |
+
|
76 |
+
# 将格式化后的日志添加到日志管理器
|
77 |
+
log_entry = {
|
78 |
+
'timestamp': datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
79 |
+
'level': level,
|
80 |
+
'key': extra.get('key', ''),
|
81 |
+
'request_type': extra.get('request_type', ''),
|
82 |
+
'model': extra.get('model', ''),
|
83 |
+
'status_code': extra.get('status_code', ''),
|
84 |
+
'message': message,
|
85 |
+
'error_message': extra.get('error_message', ''),
|
86 |
+
'formatted': formatted_log
|
87 |
+
}
|
88 |
+
log_manager.add_log(log_entry)
|
89 |
+
|
90 |
+
return formatted_log
|
91 |
+
|
92 |
+
def vertex_format_log_message(level, message, extra=None):
|
93 |
+
extra = extra or {}
|
94 |
+
log_values = {
|
95 |
+
'asctime': datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
96 |
+
'levelname': level,
|
97 |
+
'vertex_id': extra.get('vertex_id', ''),
|
98 |
+
'operation': extra.get('operation', ''),
|
99 |
+
'status': extra.get('status', ''),
|
100 |
+
'error_message': extra.get('error_message', ''),
|
101 |
+
'message': message
|
102 |
+
}
|
103 |
+
log_format = VERTEX_LOG_FORMAT_DEBUG if DEBUG else VERTEX_LOG_FORMAT_NORMAL
|
104 |
+
formatted_log = log_format % log_values
|
105 |
+
|
106 |
+
# 将格式化后的Vertex日志添加到Vertex日志管理器
|
107 |
+
log_entry = {
|
108 |
+
'timestamp': datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
109 |
+
'level': level,
|
110 |
+
'vertex_id': extra.get('vertex_id', ''),
|
111 |
+
'operation': extra.get('operation', ''),
|
112 |
+
'status': extra.get('status', ''),
|
113 |
+
'message': message,
|
114 |
+
'error_message': extra.get('error_message', ''),
|
115 |
+
'formatted': formatted_log
|
116 |
+
}
|
117 |
+
vertex_log_manager.add_log(log_entry)
|
118 |
+
|
119 |
+
return formatted_log
|
120 |
+
|
121 |
+
|
122 |
+
def log(level: str, message: str, extra: dict = None, **kwargs):
|
123 |
+
final_extra = {}
|
124 |
+
|
125 |
+
if extra is not None and isinstance(extra, dict):
|
126 |
+
final_extra.update(extra)
|
127 |
+
|
128 |
+
# 将 kwargs 中的其他关键字参数合并进来,kwargs 会覆盖 extra 中的同名键
|
129 |
+
final_extra.update(kwargs)
|
130 |
+
|
131 |
+
# 调用 format_log_message,传递合并后的 final_extra 字典
|
132 |
+
msg = format_log_message(level.upper(), message, extra=final_extra)
|
133 |
+
|
134 |
+
getattr(logger, level.lower())(msg)
|
135 |
+
|
136 |
+
def vertex_log(level: str, message: str, extra: dict = None, **kwargs):
|
137 |
+
final_extra = {}
|
138 |
+
|
139 |
+
if extra is not None and isinstance(extra, dict):
|
140 |
+
final_extra.update(extra)
|
141 |
+
|
142 |
+
# 将 kwargs 中的其他关键字参数合并进来,kwargs 会覆盖 extra 中的同名键
|
143 |
+
final_extra.update(kwargs)
|
144 |
+
|
145 |
+
# 调用 vertex_format_log_message,传递合并后的 final_extra 字典
|
146 |
+
msg = vertex_format_log_message(level.upper(), message, extra=final_extra)
|
147 |
+
|
148 |
+
getattr(logger, level.lower())(msg)
|
app/utils/maintenance.py
ADDED
@@ -0,0 +1,95 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import sys,asyncio
|
2 |
+
#from apscheduler.schedulers.background import BackgroundScheduler
|
3 |
+
from apscheduler.schedulers.asyncio import AsyncIOScheduler # 替换为异步调度器
|
4 |
+
from app.utils.logging import log
|
5 |
+
from app.utils.stats import api_stats_manager
|
6 |
+
from app.utils import check_version
|
7 |
+
from zoneinfo import ZoneInfo
|
8 |
+
from app.config import settings,persistence
|
9 |
+
import copy # 添加copy模块导入
|
10 |
+
|
11 |
+
def handle_exception(exc_type, exc_value, exc_traceback):
|
12 |
+
"""
|
13 |
+
全局异常处理函数
|
14 |
+
|
15 |
+
处理未捕获的异常,并记录到日志中
|
16 |
+
"""
|
17 |
+
if issubclass(exc_type, KeyboardInterrupt):
|
18 |
+
sys.excepthook(exc_type, exc_value, exc_traceback)
|
19 |
+
return
|
20 |
+
from app.utils.error_handling import translate_error
|
21 |
+
error_message = translate_error(str(exc_value))
|
22 |
+
log('error', f"未捕获的异常: {error_message}", status_code=500, error_message=error_message)
|
23 |
+
|
24 |
+
|
25 |
+
def schedule_cache_cleanup(response_cache_manager, active_requests_manager):
|
26 |
+
"""
|
27 |
+
设置定期清理缓存和活跃请求的定时任务
|
28 |
+
顺便定时检查更新
|
29 |
+
Args:
|
30 |
+
response_cache_manager: 响应缓存管理器实例
|
31 |
+
active_requests_manager: 活跃请求管理器实例
|
32 |
+
"""
|
33 |
+
beijing_tz = ZoneInfo("Asia/Shanghai")
|
34 |
+
scheduler = AsyncIOScheduler(timezone=beijing_tz) # 使用 AsyncIOScheduler 替代 BackgroundScheduler
|
35 |
+
|
36 |
+
# 添加任务时直接传递异步函数(无需额外包装)
|
37 |
+
scheduler.add_job(response_cache_manager.clean_expired, 'interval', minutes=1)
|
38 |
+
scheduler.add_job(active_requests_manager.clean_completed, 'interval', seconds=30)
|
39 |
+
scheduler.add_job(active_requests_manager.clean_long_running, 'interval', minutes=5, args=[300])
|
40 |
+
|
41 |
+
# 使用同步包装器调用异步函数
|
42 |
+
def run_cleanup():
|
43 |
+
try:
|
44 |
+
# 创建新的事件循环而不是获取现有的
|
45 |
+
loop = asyncio.new_event_loop()
|
46 |
+
asyncio.set_event_loop(loop)
|
47 |
+
|
48 |
+
# 在这个新循环中运行清理操作
|
49 |
+
loop.run_until_complete(api_stats_manager.cleanup())
|
50 |
+
except Exception as e:
|
51 |
+
log('error', f"清理统计数据时出错: {str(e)}")
|
52 |
+
finally:
|
53 |
+
# 确保关闭循环以释放资源
|
54 |
+
loop.close()
|
55 |
+
|
56 |
+
# 添加同步的清理任务
|
57 |
+
scheduler.add_job(run_cleanup, 'interval', minutes=5)
|
58 |
+
|
59 |
+
# 同样修改定时重置函数
|
60 |
+
def run_reset():
|
61 |
+
try:
|
62 |
+
loop = asyncio.new_event_loop()
|
63 |
+
asyncio.set_event_loop(loop)
|
64 |
+
loop.run_until_complete(api_call_stats_clean())
|
65 |
+
except Exception as e:
|
66 |
+
log('error', f"重置统计数据时出错: {str(e)}")
|
67 |
+
finally:
|
68 |
+
loop.close()
|
69 |
+
|
70 |
+
scheduler.add_job(check_version, 'interval', hours=4)
|
71 |
+
scheduler.add_job(run_reset, 'cron', hour=15, minute=0)
|
72 |
+
scheduler.start()
|
73 |
+
return scheduler
|
74 |
+
|
75 |
+
async def api_call_stats_clean():
|
76 |
+
"""
|
77 |
+
每天定时重置API调用统计数据
|
78 |
+
|
79 |
+
使用新的统计系统重置
|
80 |
+
"""
|
81 |
+
from app.utils.logging import log
|
82 |
+
|
83 |
+
try:
|
84 |
+
# 记录重置前的状态
|
85 |
+
log('info', "开始重置API调用统计数据")
|
86 |
+
|
87 |
+
# 使用新的统计系统重置
|
88 |
+
await api_stats_manager.reset()
|
89 |
+
|
90 |
+
log('info', "API调用统计数据已成功重置")
|
91 |
+
persistence.save_settings()
|
92 |
+
|
93 |
+
except Exception as e:
|
94 |
+
log('error', f"重置API调用统计数据时发生错误: {str(e)}")
|
95 |
+
raise
|
app/utils/rate_limiting.py
ADDED
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import time
|
2 |
+
import asyncio
|
3 |
+
from fastapi import HTTPException, Request
|
4 |
+
|
5 |
+
rate_limit_data = {}
|
6 |
+
rate_limit_lock = asyncio.Lock()
|
7 |
+
|
8 |
+
async def protect_from_abuse(request: Request, max_requests_per_minute: int = 30, max_requests_per_day_per_ip: int = 600):
|
9 |
+
now = int(time.time())
|
10 |
+
minute = now // 60
|
11 |
+
day = now // (60 * 60 * 24)
|
12 |
+
|
13 |
+
minute_key = f"{request.url.path}:{minute}"
|
14 |
+
day_key = f"{request.client.host}:{day}"
|
15 |
+
|
16 |
+
async with rate_limit_lock:
|
17 |
+
minute_count, minute_timestamp = rate_limit_data.get(minute_key, (0, now))
|
18 |
+
|
19 |
+
if now - minute_timestamp >= 60:
|
20 |
+
minute_count = 0
|
21 |
+
minute_timestamp = now
|
22 |
+
minute_count += 1
|
23 |
+
rate_limit_data[minute_key] = (minute_count, minute_timestamp)
|
24 |
+
|
25 |
+
day_count, day_timestamp = rate_limit_data.get(day_key, (0, now))
|
26 |
+
if now - day_timestamp >= 86400:
|
27 |
+
day_count = 0
|
28 |
+
day_timestamp = now
|
29 |
+
day_count += 1
|
30 |
+
rate_limit_data[day_key] = (day_count, day_timestamp)
|
31 |
+
|
32 |
+
if minute_count > max_requests_per_minute:
|
33 |
+
raise HTTPException(status_code=429, detail={
|
34 |
+
"message": "Too many requests per minute", "limit": max_requests_per_minute})
|
35 |
+
if day_count > max_requests_per_day_per_ip:
|
36 |
+
raise HTTPException(status_code=429, detail={"message": "Too many requests per day from this IP", "limit": max_requests_per_day_per_ip})
|
app/utils/request.py
ADDED
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import asyncio
|
2 |
+
import time
|
3 |
+
from typing import Dict, Any
|
4 |
+
from app.utils.logging import log
|
5 |
+
|
6 |
+
class ActiveRequestsManager:
|
7 |
+
"""管理活跃API请求的类"""
|
8 |
+
|
9 |
+
def __init__(self, requests_pool: Dict[str, asyncio.Task] = None):
|
10 |
+
self.active_requests = requests_pool if requests_pool is not None else {} # 存储活跃请求
|
11 |
+
|
12 |
+
def add(self, key: str, task: asyncio.Task):
|
13 |
+
"""添加新的活跃请求任务"""
|
14 |
+
task.creation_time = time.time() # 添加创建时间属性
|
15 |
+
self.active_requests[key] = task
|
16 |
+
|
17 |
+
def get(self, key: str):
|
18 |
+
"""获取活跃请求任务"""
|
19 |
+
return self.active_requests.get(key)
|
20 |
+
|
21 |
+
def remove(self, key: str):
|
22 |
+
"""移除活跃请求任务"""
|
23 |
+
if key in self.active_requests:
|
24 |
+
del self.active_requests[key]
|
25 |
+
return True
|
26 |
+
return False
|
27 |
+
|
28 |
+
def clean_completed(self):
|
29 |
+
"""清理所有已完成或已取消的任务"""
|
30 |
+
|
31 |
+
for key, task in self.active_requests.items():
|
32 |
+
if task.done() or task.cancelled():
|
33 |
+
del self.active_requests[key]
|
34 |
+
|
35 |
+
# if keys_to_remove:
|
36 |
+
# log('info', f"清理已完成请求任务: {len(keys_to_remove)}个", cleanup='active_requests')
|
37 |
+
|
38 |
+
def clean_long_running(self, max_age_seconds: int = 300):
|
39 |
+
"""清理长时间运行的任务"""
|
40 |
+
now = time.time()
|
41 |
+
long_running_keys = []
|
42 |
+
|
43 |
+
for key, task in list(self.active_requests.items()):
|
44 |
+
if (hasattr(task, 'creation_time') and
|
45 |
+
task.creation_time < now - max_age_seconds and
|
46 |
+
not task.done() and not task.cancelled()):
|
47 |
+
|
48 |
+
long_running_keys.append(key)
|
49 |
+
task.cancel() # 取消长时间运行的任务
|
50 |
+
|
51 |
+
if long_running_keys:
|
52 |
+
log('warning', f"取消长时间运行的任务: {len(long_running_keys)}个", cleanup='long_running_tasks')
|
app/utils/response.py
ADDED
@@ -0,0 +1,130 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import json
|
2 |
+
import time
|
3 |
+
from app.utils.logging import log
|
4 |
+
|
5 |
+
def openAI_from_text(model="gemini",content=None,finish_reason=None,total_token_count=0,stream=True):
|
6 |
+
"""
|
7 |
+
根据传入参数,创建 OpenAI 标准响应对象块
|
8 |
+
"""
|
9 |
+
|
10 |
+
now_time = int(time.time())
|
11 |
+
content_chunk = {}
|
12 |
+
formatted_chunk = {
|
13 |
+
"id": f"chatcmpl-{now_time}",
|
14 |
+
"created": now_time,
|
15 |
+
"model": model,
|
16 |
+
"choices": [{"index": 0 , "finish_reason": finish_reason}]
|
17 |
+
}
|
18 |
+
|
19 |
+
if content:
|
20 |
+
content_chunk = {"role": "assistant", "content": content}
|
21 |
+
|
22 |
+
if finish_reason:
|
23 |
+
formatted_chunk["usage"]= {"total_tokens": total_token_count}
|
24 |
+
|
25 |
+
if stream:
|
26 |
+
formatted_chunk["choices"][0]["delta"] = content_chunk
|
27 |
+
formatted_chunk["object"] = "chat.completion.chunk"
|
28 |
+
return f"data: {json.dumps(formatted_chunk, ensure_ascii=False)}\n\n"
|
29 |
+
else:
|
30 |
+
formatted_chunk["choices"][0]["message"] = content_chunk
|
31 |
+
formatted_chunk["object"] = "chat.completion"
|
32 |
+
return formatted_chunk
|
33 |
+
|
34 |
+
def gemini_from_text(content=None, finish_reason=None, total_token_count=0, stream=True):
|
35 |
+
"""
|
36 |
+
根据传入参数,创建 Gemini API 标准响应对象块 (GenerateContentResponse 格式)。
|
37 |
+
"""
|
38 |
+
gemini_response = {
|
39 |
+
"candidates": {
|
40 |
+
"index": 0,
|
41 |
+
"content": {
|
42 |
+
"parts": [],
|
43 |
+
"role": "model"
|
44 |
+
}
|
45 |
+
}
|
46 |
+
}
|
47 |
+
if content:
|
48 |
+
gemini_response["candidates"]["content"]["parts"].append({"text": content})
|
49 |
+
|
50 |
+
if finish_reason:
|
51 |
+
gemini_response["usageMetadata"]= {"totalTokenCount": total_token_count}
|
52 |
+
|
53 |
+
if stream:
|
54 |
+
return f"data: {json.dumps(gemini_response, ensure_ascii=False)}\n\n"
|
55 |
+
else:
|
56 |
+
return gemini_response
|
57 |
+
|
58 |
+
|
59 |
+
def openAI_from_Gemini(response,stream=True):
|
60 |
+
"""
|
61 |
+
根据 GeminiResponseWrapper 对象创建 OpenAI 标准响应对象块。
|
62 |
+
|
63 |
+
Args:
|
64 |
+
response: GeminiResponseWrapper 对象,包含响应数据。
|
65 |
+
|
66 |
+
Returns:
|
67 |
+
OpenAI 标准响应
|
68 |
+
"""
|
69 |
+
now_time = int(time.time())
|
70 |
+
chunk_id = f"chatcmpl-{now_time}" # 使用时间戳生成唯一 ID
|
71 |
+
content_chunk = {}
|
72 |
+
formatted_chunk = {
|
73 |
+
"id": chunk_id,
|
74 |
+
"created": now_time,
|
75 |
+
"model": response.model,
|
76 |
+
"choices": [{"index": 0 , "finish_reason": response.finish_reason}]
|
77 |
+
}
|
78 |
+
|
79 |
+
# 准备 usage 数据,处理属性缺失或为 None 的情况
|
80 |
+
prompt_tokens_raw = getattr(response, 'prompt_token_count', None)
|
81 |
+
candidates_tokens_raw = getattr(response, 'candidates_token_count', None)
|
82 |
+
total_tokens_raw = getattr(response, 'total_token_count', None)
|
83 |
+
|
84 |
+
usage_data = {
|
85 |
+
"prompt_tokens": int(prompt_tokens_raw) if prompt_tokens_raw else 0,
|
86 |
+
"completion_tokens": int(candidates_tokens_raw) if candidates_tokens_raw else 0,
|
87 |
+
"total_tokens": int(total_tokens_raw) if total_tokens_raw else 0
|
88 |
+
}
|
89 |
+
|
90 |
+
if response.function_call:
|
91 |
+
tool_calls=[]
|
92 |
+
# 处理函数调用的每一部分
|
93 |
+
for part in response.function_call:
|
94 |
+
function_name = part.get("name")
|
95 |
+
# Gemini 的 args 是 dict, OpenAI 需要 string
|
96 |
+
function_args_str = json.dumps(part.get("args", {}), ensure_ascii=False)
|
97 |
+
|
98 |
+
tool_call_id = f"call_{function_name}" # 编码函数名到 ID
|
99 |
+
tool_calls.append({
|
100 |
+
"id": tool_call_id,
|
101 |
+
"type": "function",
|
102 |
+
"function": {
|
103 |
+
"name": function_name,
|
104 |
+
"arguments": function_args_str,
|
105 |
+
}
|
106 |
+
})
|
107 |
+
|
108 |
+
content_chunk = {
|
109 |
+
"role": "assistant",
|
110 |
+
"content": None, # 函数调用时 content 为 null
|
111 |
+
"tool_calls": tool_calls
|
112 |
+
}
|
113 |
+
elif response.text:
|
114 |
+
# 处理普通文本响应
|
115 |
+
content_chunk = {"role": "assistant", "content": response.text}
|
116 |
+
|
117 |
+
if stream:
|
118 |
+
formatted_chunk["choices"][0]["delta"] = content_chunk
|
119 |
+
formatted_chunk["object"] = "chat.completion.chunk"
|
120 |
+
# 仅在流结束时添加 usage 字段
|
121 |
+
if response.finish_reason:
|
122 |
+
formatted_chunk["usage"] = usage_data
|
123 |
+
return f"data: {json.dumps(formatted_chunk, ensure_ascii=False)}\n\n"
|
124 |
+
|
125 |
+
else:
|
126 |
+
formatted_chunk["choices"][0]["message"] = content_chunk
|
127 |
+
formatted_chunk["object"] = "chat.completion"
|
128 |
+
# 非流式响应总是包含 usage 字段,以满足 response_model 验证
|
129 |
+
formatted_chunk["usage"] = usage_data
|
130 |
+
return formatted_chunk
|
app/utils/stats.py
ADDED
@@ -0,0 +1,299 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import asyncio
|
2 |
+
from datetime import datetime, timedelta
|
3 |
+
from app.utils.logging import log
|
4 |
+
import app.config.settings as settings
|
5 |
+
from collections import defaultdict, Counter
|
6 |
+
import time
|
7 |
+
import threading
|
8 |
+
import queue
|
9 |
+
import functools
|
10 |
+
|
11 |
+
class ApiStatsManager:
|
12 |
+
"""API调用统计管理器,优化性能的新实现"""
|
13 |
+
|
14 |
+
def __init__(self, enable_background=True, batch_interval=1.0):
|
15 |
+
# 使用Counter记录API密钥和模型的调用次数
|
16 |
+
self.api_key_counts = Counter() # 记录每个API密钥的调用次数
|
17 |
+
self.model_counts = Counter() # 记录每个模型的调用次数
|
18 |
+
self.api_model_counts = defaultdict(Counter) # 记录每个API密钥对每个模型的调用次数
|
19 |
+
|
20 |
+
# 记录token使用量
|
21 |
+
self.api_key_tokens = Counter() # 记录每个API密钥的token使用量
|
22 |
+
self.model_tokens = Counter() # 记录每个模型的token使用量
|
23 |
+
self.api_model_tokens = defaultdict(Counter) # 记录每个API密钥对每个模型的token使用量
|
24 |
+
|
25 |
+
# 用于时间序列分析的数据结构(最近24小时,按分钟分组)
|
26 |
+
self.time_buckets = {} # 格式: {timestamp_minute: {"calls": count, "tokens": count}}
|
27 |
+
|
28 |
+
# 保存与兼容格式相关的调用日志(最小化存储)
|
29 |
+
self.recent_calls = [] # 仅保存最近的少量调用,用于前端展示
|
30 |
+
self.max_recent_calls = 100 # 最大保存的最近调用记录数
|
31 |
+
|
32 |
+
# 当前时间分钟桶的时间戳(分钟级别)
|
33 |
+
self.current_minute = self._get_minute_timestamp(datetime.now())
|
34 |
+
|
35 |
+
# 清理间隔(小时)
|
36 |
+
self.cleanup_interval = 1
|
37 |
+
self.last_cleanup = time.time()
|
38 |
+
|
39 |
+
# 使用线程锁而不是asyncio锁
|
40 |
+
self._counters_lock = threading.Lock()
|
41 |
+
self._time_series_lock = threading.Lock()
|
42 |
+
self._recent_calls_lock = threading.Lock()
|
43 |
+
|
44 |
+
# 后台处理相关
|
45 |
+
self.enable_background = enable_background
|
46 |
+
self.batch_interval = batch_interval
|
47 |
+
self._update_queue = queue.Queue()
|
48 |
+
self._worker_thread = None
|
49 |
+
self._stop_event = threading.Event()
|
50 |
+
|
51 |
+
if enable_background:
|
52 |
+
self._start_worker()
|
53 |
+
|
54 |
+
def _start_worker(self):
|
55 |
+
"""启动后台工作线程"""
|
56 |
+
if self._worker_thread is None or not self._worker_thread.is_alive():
|
57 |
+
self._stop_event.clear()
|
58 |
+
self._worker_thread = threading.Thread(
|
59 |
+
target=self._worker_loop,
|
60 |
+
daemon=True
|
61 |
+
)
|
62 |
+
self._worker_thread.start()
|
63 |
+
|
64 |
+
def _worker_loop(self):
|
65 |
+
"""后台工作线程的主循环"""
|
66 |
+
batch = []
|
67 |
+
last_process = time.time()
|
68 |
+
|
69 |
+
while not self._stop_event.is_set():
|
70 |
+
try:
|
71 |
+
# 非阻塞获取更新
|
72 |
+
try:
|
73 |
+
update = self._update_queue.get_nowait()
|
74 |
+
batch.append(update)
|
75 |
+
except queue.Empty:
|
76 |
+
pass
|
77 |
+
|
78 |
+
# 处理批次或超时
|
79 |
+
current_time = time.time()
|
80 |
+
if batch and (current_time - last_process >= self.batch_interval):
|
81 |
+
self._process_batch(batch)
|
82 |
+
batch = []
|
83 |
+
last_process = current_time
|
84 |
+
|
85 |
+
# 短暂休眠以避免CPU占用过高
|
86 |
+
time.sleep(0.01)
|
87 |
+
|
88 |
+
except Exception as e:
|
89 |
+
log('error', f"后台处理线程错误: {str(e)}")
|
90 |
+
time.sleep(1) # 发生错误时短暂休眠
|
91 |
+
|
92 |
+
def _process_batch(self, batch):
|
93 |
+
"""处理一批更新"""
|
94 |
+
with self._counters_lock:
|
95 |
+
for api_key, model, tokens in batch:
|
96 |
+
self.api_key_counts[api_key] += 1
|
97 |
+
self.model_counts[model] += 1
|
98 |
+
self.api_model_counts[api_key][model] += 1
|
99 |
+
self.api_key_tokens[api_key] += tokens
|
100 |
+
self.model_tokens[model] += tokens
|
101 |
+
self.api_model_tokens[api_key][model] += tokens
|
102 |
+
|
103 |
+
async def update_stats(self, api_key, model, tokens=0):
|
104 |
+
"""更新API调用统计"""
|
105 |
+
if self.enable_background:
|
106 |
+
# 将更新放入队列
|
107 |
+
self._update_queue.put((api_key, model, tokens))
|
108 |
+
else:
|
109 |
+
# 同步更新
|
110 |
+
with self._counters_lock:
|
111 |
+
self.api_key_counts[api_key] += 1
|
112 |
+
self.model_counts[model] += 1
|
113 |
+
self.api_model_counts[api_key][model] += 1
|
114 |
+
self.api_key_tokens[api_key] += tokens
|
115 |
+
self.model_tokens[model] += tokens
|
116 |
+
self.api_model_tokens[api_key][model] += tokens
|
117 |
+
|
118 |
+
# 更新时间序列数据
|
119 |
+
now = datetime.now()
|
120 |
+
minute_ts = self._get_minute_timestamp(now)
|
121 |
+
|
122 |
+
with self._time_series_lock:
|
123 |
+
if minute_ts not in self.time_buckets:
|
124 |
+
self.time_buckets[minute_ts] = {"calls": 0, "tokens": 0}
|
125 |
+
|
126 |
+
self.time_buckets[minute_ts]["calls"] += 1
|
127 |
+
self.time_buckets[minute_ts]["tokens"] += tokens
|
128 |
+
self.current_minute = minute_ts
|
129 |
+
|
130 |
+
# 更新最近调用记录
|
131 |
+
with self._recent_calls_lock:
|
132 |
+
compact_call = {
|
133 |
+
'api_key': api_key,
|
134 |
+
'model': model,
|
135 |
+
'timestamp': now,
|
136 |
+
'tokens': tokens
|
137 |
+
}
|
138 |
+
|
139 |
+
self.recent_calls.append(compact_call)
|
140 |
+
if len(self.recent_calls) > self.max_recent_calls:
|
141 |
+
self.recent_calls.pop(0)
|
142 |
+
|
143 |
+
# 记录日志
|
144 |
+
log_message = f"API调用已记录: 秘钥 '{api_key[:8]}', 模型 '{model}', 令牌: {tokens if tokens is not None else 0}"
|
145 |
+
log('info', log_message)
|
146 |
+
|
147 |
+
async def cleanup(self):
|
148 |
+
"""清理超过24小时的时间桶数据"""
|
149 |
+
now = datetime.now()
|
150 |
+
day_ago_ts = self._get_minute_timestamp(now - timedelta(days=1))
|
151 |
+
|
152 |
+
with self._time_series_lock:
|
153 |
+
# 直接删除旧的时间桶
|
154 |
+
for ts in list(self.time_buckets.keys()):
|
155 |
+
if ts < day_ago_ts:
|
156 |
+
del self.time_buckets[ts]
|
157 |
+
|
158 |
+
self.last_cleanup = time.time()
|
159 |
+
|
160 |
+
async def maybe_cleanup(self, force=False):
|
161 |
+
"""根据需要清理旧数据"""
|
162 |
+
now = time.time()
|
163 |
+
if force or (now - self.last_cleanup > self.cleanup_interval * 3600):
|
164 |
+
await self.cleanup()
|
165 |
+
self.last_cleanup = now
|
166 |
+
|
167 |
+
async def get_api_key_usage(self, api_key, model=None):
|
168 |
+
"""获取API密钥的使用统计"""
|
169 |
+
with self._counters_lock:
|
170 |
+
if model:
|
171 |
+
return self.api_model_counts[api_key][model]
|
172 |
+
else:
|
173 |
+
return self.api_key_counts[api_key]
|
174 |
+
|
175 |
+
def get_calls_last_24h(self):
|
176 |
+
"""获取过去24小时的总调用次数"""
|
177 |
+
with self._counters_lock:
|
178 |
+
return sum(self.api_key_counts.values())
|
179 |
+
|
180 |
+
def get_calls_last_hour(self, now=None):
|
181 |
+
"""获取过去一小时的总调用次数"""
|
182 |
+
if now is None:
|
183 |
+
now = datetime.now()
|
184 |
+
|
185 |
+
hour_ago_ts = self._get_minute_timestamp(now - timedelta(hours=1))
|
186 |
+
|
187 |
+
with self._time_series_lock:
|
188 |
+
return sum(data["calls"] for ts, data in self.time_buckets.items()
|
189 |
+
if ts >= hour_ago_ts)
|
190 |
+
|
191 |
+
def get_calls_last_minute(self, now=None):
|
192 |
+
"""获取过去一分钟的总调用次数"""
|
193 |
+
if now is None:
|
194 |
+
now = datetime.now()
|
195 |
+
|
196 |
+
minute_ago_ts = self._get_minute_timestamp(now - timedelta(minutes=1))
|
197 |
+
|
198 |
+
with self._time_series_lock:
|
199 |
+
return sum(data["calls"] for ts, data in self.time_buckets.items()
|
200 |
+
if ts >= minute_ago_ts)
|
201 |
+
|
202 |
+
def get_time_series_data(self, minutes=30, now=None):
|
203 |
+
"""获取过去N分钟的时间序列数据"""
|
204 |
+
if now is None:
|
205 |
+
now = datetime.now()
|
206 |
+
|
207 |
+
calls_series = []
|
208 |
+
tokens_series = []
|
209 |
+
|
210 |
+
with self._time_series_lock:
|
211 |
+
for i in range(minutes, -1, -1):
|
212 |
+
minute_dt = now - timedelta(minutes=i)
|
213 |
+
minute_ts = self._get_minute_timestamp(minute_dt)
|
214 |
+
|
215 |
+
bucket = self.time_buckets.get(minute_ts, {"calls": 0, "tokens": 0})
|
216 |
+
|
217 |
+
calls_series.append({
|
218 |
+
'time': minute_dt.strftime('%H:%M'),
|
219 |
+
'value': bucket["calls"]
|
220 |
+
})
|
221 |
+
|
222 |
+
tokens_series.append({
|
223 |
+
'time': minute_dt.strftime('%H:%M'),
|
224 |
+
'value': bucket["tokens"]
|
225 |
+
})
|
226 |
+
|
227 |
+
return calls_series, tokens_series
|
228 |
+
|
229 |
+
def get_api_key_stats(self, api_keys):
|
230 |
+
"""获取API密钥的详细统计信息"""
|
231 |
+
stats = []
|
232 |
+
|
233 |
+
with self._counters_lock:
|
234 |
+
for api_key in api_keys:
|
235 |
+
api_key_id = api_key[:8]
|
236 |
+
calls_24h = self.api_key_counts[api_key]
|
237 |
+
total_tokens = self.api_key_tokens[api_key]
|
238 |
+
|
239 |
+
model_stats = {}
|
240 |
+
for model, count in self.api_model_counts[api_key].items():
|
241 |
+
tokens = self.api_model_tokens[api_key][model]
|
242 |
+
model_stats[model] = {
|
243 |
+
'calls': count,
|
244 |
+
'tokens': tokens
|
245 |
+
}
|
246 |
+
|
247 |
+
usage_percent = (calls_24h / settings.API_KEY_DAILY_LIMIT) * 100 if settings.API_KEY_DAILY_LIMIT > 0 else 0
|
248 |
+
|
249 |
+
stats.append({
|
250 |
+
'api_key': api_key_id,
|
251 |
+
'calls_24h': calls_24h,
|
252 |
+
'total_tokens': total_tokens,
|
253 |
+
'limit': settings.API_KEY_DAILY_LIMIT,
|
254 |
+
'usage_percent': round(usage_percent, 2),
|
255 |
+
'model_stats': model_stats
|
256 |
+
})
|
257 |
+
|
258 |
+
stats.sort(key=lambda x: x['usage_percent'], reverse=True)
|
259 |
+
return stats
|
260 |
+
|
261 |
+
async def reset(self):
|
262 |
+
"""重置所有统计数据"""
|
263 |
+
with self._counters_lock:
|
264 |
+
self.api_key_counts.clear()
|
265 |
+
self.model_counts.clear()
|
266 |
+
self.api_model_counts.clear()
|
267 |
+
self.api_key_tokens.clear()
|
268 |
+
self.model_tokens.clear()
|
269 |
+
self.api_model_tokens.clear()
|
270 |
+
|
271 |
+
with self._time_series_lock:
|
272 |
+
self.time_buckets.clear()
|
273 |
+
|
274 |
+
with self._recent_calls_lock:
|
275 |
+
self.recent_calls.clear()
|
276 |
+
|
277 |
+
self.current_minute = self._get_minute_timestamp(datetime.now())
|
278 |
+
self.last_cleanup = time.time()
|
279 |
+
|
280 |
+
def _get_minute_timestamp(self, dt):
|
281 |
+
"""将时间戳转换为分钟级别的时间戳(按分钟取整)"""
|
282 |
+
return int(dt.timestamp() // 60 * 60)
|
283 |
+
|
284 |
+
# 创建全局单例实例
|
285 |
+
api_stats_manager = ApiStatsManager()
|
286 |
+
|
287 |
+
# 兼容现有代码的函数
|
288 |
+
def clean_expired_stats(api_call_stats):
|
289 |
+
"""清理过期统计数据的函数 (兼容旧接口)"""
|
290 |
+
asyncio.create_task(api_stats_manager.cleanup())
|
291 |
+
|
292 |
+
async def update_api_call_stats(api_call_stats, endpoint=None, model=None, token=None):
|
293 |
+
"""更新API调用统计的函数 (兼容旧接口)"""
|
294 |
+
if endpoint and model:
|
295 |
+
await api_stats_manager.update_stats(endpoint, model, token if token is not None else 0)
|
296 |
+
|
297 |
+
async def get_api_key_usage(api_call_stats, api_key, model=None):
|
298 |
+
"""获取API密钥的调用次数 (兼容旧接口)"""
|
299 |
+
return await api_stats_manager.get_api_key_usage(api_key, model)
|
app/utils/version.py
ADDED
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import requests
|
2 |
+
from app.utils.logging import log
|
3 |
+
import app.config.settings as settings
|
4 |
+
async def check_version():
|
5 |
+
"""
|
6 |
+
检查应用程序版本更新
|
7 |
+
|
8 |
+
从本地和远程获取版本信息,并比较版本号以确定是否有更新
|
9 |
+
"""
|
10 |
+
# 导入全局变量
|
11 |
+
try:
|
12 |
+
# 读取本地版本
|
13 |
+
with open("./version.txt", "r") as f:
|
14 |
+
version_line = f.read().strip()
|
15 |
+
settings.version['local_version'] = version_line.split("=")[1] if "=" in version_line else "0.0.0"
|
16 |
+
|
17 |
+
# 获取远程版本
|
18 |
+
github_url = "https://raw.githubusercontent.com/wyeeeee/hajimi/refs/heads/main/version.txt"
|
19 |
+
response = requests.get(github_url, timeout=5)
|
20 |
+
if response.status_code == 200:
|
21 |
+
version_line = response.text.strip()
|
22 |
+
settings.version['remote_version']= version_line.split("=")[1] if "=" in version_line else "0.0.0"
|
23 |
+
# 比较版本号
|
24 |
+
local_parts = [int(x) for x in settings.version['local_version'].split(".")]
|
25 |
+
remote_parts = [int(x) for x in settings.version['remote_version'].split(".")]
|
26 |
+
|
27 |
+
# 确保两个列表长度相同
|
28 |
+
while len(local_parts) < len(remote_parts):
|
29 |
+
local_parts.append(0)
|
30 |
+
while len(remote_parts) < len(local_parts):
|
31 |
+
remote_parts.append(0)
|
32 |
+
|
33 |
+
# 比较版本号
|
34 |
+
settings.version['has_update'] = False
|
35 |
+
for i in range(len(local_parts)):
|
36 |
+
if remote_parts[i] > local_parts[i]:
|
37 |
+
settings.version['has_update'] = True
|
38 |
+
break
|
39 |
+
elif remote_parts[i] < local_parts[i]:
|
40 |
+
break
|
41 |
+
|
42 |
+
log('info', f"版本检查: 本地版本 {settings.version['local_version']}, 远程版本 {settings.version['remote_version']}, 有更新: {settings.version['has_update']}")
|
43 |
+
else:
|
44 |
+
log('warning', f"无法获取远程版本信息,HTTP状态码: {response.status_code}")
|
45 |
+
except Exception as e:
|
46 |
+
log('error', f"版本检查失败: {str(e)}")
|
47 |
+
|
48 |
+
return settings.version['has_update']
|
app/vertex/__init__.py
ADDED
@@ -0,0 +1 @@
|
|
|
|
|
1 |
+
# This file makes the 'app' directory a Python package.
|
app/vertex/api_helpers.py
ADDED
@@ -0,0 +1,317 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import json
|
2 |
+
import time
|
3 |
+
import math
|
4 |
+
import asyncio
|
5 |
+
from typing import List, Dict, Any, Callable, Union, Optional
|
6 |
+
from fastapi.responses import JSONResponse, StreamingResponse
|
7 |
+
|
8 |
+
from google.auth.transport.requests import Request as AuthRequest
|
9 |
+
from google.genai import types
|
10 |
+
from google import genai # Needed if _execute_gemini_call uses genai.Client directly
|
11 |
+
from app.vertex.message_processing import parse_gemini_response_for_reasoning_and_content
|
12 |
+
# Local module imports
|
13 |
+
from app.vertex.models import OpenAIRequest, OpenAIMessage # Changed from relative
|
14 |
+
from app.vertex.message_processing import deobfuscate_text, convert_to_openai_format, convert_chunk_to_openai, create_final_chunk # Changed from relative
|
15 |
+
import app.vertex.config as app_config # Changed from relative
|
16 |
+
from app.config import settings # 导入settings模块
|
17 |
+
|
18 |
+
def create_openai_error_response(status_code: int, message: str, error_type: str) -> Dict[str, Any]:
|
19 |
+
return {
|
20 |
+
"error": {
|
21 |
+
"message": message,
|
22 |
+
"type": error_type,
|
23 |
+
"code": status_code,
|
24 |
+
"param": None,
|
25 |
+
}
|
26 |
+
}
|
27 |
+
|
28 |
+
def create_generation_config(request: OpenAIRequest) -> Dict[str, Any]:
|
29 |
+
config = {}
|
30 |
+
if request.temperature is not None: config["temperature"] = request.temperature
|
31 |
+
if request.max_tokens is not None: config["max_output_tokens"] = request.max_tokens
|
32 |
+
if request.top_p is not None: config["top_p"] = request.top_p
|
33 |
+
if request.top_k is not None: config["top_k"] = request.top_k
|
34 |
+
if request.stop is not None: config["stop_sequences"] = request.stop
|
35 |
+
if request.seed is not None: config["seed"] = request.seed
|
36 |
+
if request.presence_penalty is not None: config["presence_penalty"] = request.presence_penalty
|
37 |
+
if request.frequency_penalty is not None: config["frequency_penalty"] = request.frequency_penalty
|
38 |
+
if request.n is not None: config["candidate_count"] = request.n
|
39 |
+
config["safety_settings"] = [
|
40 |
+
types.SafetySetting(category="HARM_CATEGORY_HATE_SPEECH", threshold="OFF"),
|
41 |
+
types.SafetySetting(category="HARM_CATEGORY_DANGEROUS_CONTENT", threshold="OFF"),
|
42 |
+
types.SafetySetting(category="HARM_CATEGORY_SEXUALLY_EXPLICIT", threshold="OFF"),
|
43 |
+
types.SafetySetting(category="HARM_CATEGORY_HARASSMENT", threshold="OFF"),
|
44 |
+
types.SafetySetting(category="HARM_CATEGORY_CIVIC_INTEGRITY", threshold="OFF")
|
45 |
+
]
|
46 |
+
return config
|
47 |
+
|
48 |
+
def is_response_valid(response):
|
49 |
+
if response is None:
|
50 |
+
print("DEBUG: Response is None, therefore invalid.")
|
51 |
+
return False
|
52 |
+
|
53 |
+
# Check for direct text attribute
|
54 |
+
if hasattr(response, 'text') and isinstance(response.text, str) and response.text.strip():
|
55 |
+
# print("DEBUG: Response valid due to response.text")
|
56 |
+
return True
|
57 |
+
|
58 |
+
# Check candidates for text content
|
59 |
+
if hasattr(response, 'candidates') and response.candidates:
|
60 |
+
for candidate in response.candidates: # Iterate through all candidates
|
61 |
+
if hasattr(candidate, 'text') and isinstance(candidate.text, str) and candidate.text.strip():
|
62 |
+
# print(f"DEBUG: Response valid due to candidate.text in candidate")
|
63 |
+
return True
|
64 |
+
if hasattr(candidate, 'content') and hasattr(candidate.content, 'parts') and candidate.content.parts:
|
65 |
+
for part in candidate.content.parts:
|
66 |
+
if hasattr(part, 'text') and isinstance(part.text, str) and part.text.strip():
|
67 |
+
# print(f"DEBUG: Response valid due to part.text in candidate's content part")
|
68 |
+
return True
|
69 |
+
|
70 |
+
# Removed prompt_feedback as a sole criterion for validity.
|
71 |
+
# It should only be valid if actual text content is found.
|
72 |
+
# Block reasons will be checked explicitly by callers if they need to treat it as an error for retries.
|
73 |
+
print("DEBUG: Response is invalid, no usable text content found by is_response_valid.")
|
74 |
+
return False
|
75 |
+
|
76 |
+
async def _base_fake_stream_engine(
|
77 |
+
api_call_task_creator: Callable[[], asyncio.Task],
|
78 |
+
extract_text_from_response_func: Callable[[Any], str],
|
79 |
+
response_id: str,
|
80 |
+
sse_model_name: str,
|
81 |
+
is_auto_attempt: bool,
|
82 |
+
is_valid_response_func: Callable[[Any], bool],
|
83 |
+
keep_alive_interval_seconds: float,
|
84 |
+
process_text_func: Optional[Callable[[str, str], str]] = None,
|
85 |
+
check_block_reason_func: Optional[Callable[[Any], None]] = None,
|
86 |
+
reasoning_text_to_yield: Optional[str] = None,
|
87 |
+
actual_content_text_to_yield: Optional[str] = None
|
88 |
+
):
|
89 |
+
api_call_task = api_call_task_creator()
|
90 |
+
|
91 |
+
if keep_alive_interval_seconds > 0:
|
92 |
+
while not api_call_task.done():
|
93 |
+
keep_alive_data = {"id": "chatcmpl-keepalive", "object": "chat.completion.chunk", "created": int(time.time()), "model": sse_model_name, "choices": [{"delta": {"reasoning_content": ""}, "index": 0, "finish_reason": None}]}
|
94 |
+
yield f"data: {json.dumps(keep_alive_data)}\n\n"
|
95 |
+
await asyncio.sleep(keep_alive_interval_seconds)
|
96 |
+
|
97 |
+
try:
|
98 |
+
full_api_response = await api_call_task
|
99 |
+
|
100 |
+
if check_block_reason_func:
|
101 |
+
check_block_reason_func(full_api_response)
|
102 |
+
|
103 |
+
if not is_valid_response_func(full_api_response):
|
104 |
+
raise ValueError(f"Invalid/empty API response in fake stream for model {sse_model_name}: {str(full_api_response)[:200]}")
|
105 |
+
|
106 |
+
final_reasoning_text = reasoning_text_to_yield
|
107 |
+
final_actual_content_text = actual_content_text_to_yield
|
108 |
+
|
109 |
+
if final_reasoning_text is None and final_actual_content_text is None:
|
110 |
+
extracted_full_text = extract_text_from_response_func(full_api_response)
|
111 |
+
if process_text_func:
|
112 |
+
final_actual_content_text = process_text_func(extracted_full_text, sse_model_name)
|
113 |
+
else:
|
114 |
+
final_actual_content_text = extracted_full_text
|
115 |
+
else:
|
116 |
+
if process_text_func:
|
117 |
+
if final_reasoning_text is not None:
|
118 |
+
final_reasoning_text = process_text_func(final_reasoning_text, sse_model_name)
|
119 |
+
if final_actual_content_text is not None:
|
120 |
+
final_actual_content_text = process_text_func(final_actual_content_text, sse_model_name)
|
121 |
+
|
122 |
+
if final_reasoning_text:
|
123 |
+
reasoning_delta_data = {
|
124 |
+
"id": response_id, "object": "chat.completion.chunk", "created": int(time.time()),
|
125 |
+
"model": sse_model_name, "choices": [{"index": 0, "delta": {"reasoning_content": final_reasoning_text}, "finish_reason": None}]
|
126 |
+
}
|
127 |
+
yield f"data: {json.dumps(reasoning_delta_data)}\n\n"
|
128 |
+
if final_actual_content_text:
|
129 |
+
await asyncio.sleep(0.05)
|
130 |
+
|
131 |
+
content_to_chunk = final_actual_content_text or ""
|
132 |
+
chunk_size = max(20, math.ceil(len(content_to_chunk) / 10)) if content_to_chunk else 0
|
133 |
+
|
134 |
+
if not content_to_chunk and content_to_chunk != "":
|
135 |
+
empty_delta_data = {"id": response_id, "object": "chat.completion.chunk", "created": int(time.time()), "model": sse_model_name, "choices": [{"index": 0, "delta": {"content": ""}, "finish_reason": None}]}
|
136 |
+
yield f"data: {json.dumps(empty_delta_data)}\n\n"
|
137 |
+
else:
|
138 |
+
for i in range(0, len(content_to_chunk), chunk_size):
|
139 |
+
chunk_text = content_to_chunk[i:i+chunk_size]
|
140 |
+
content_delta_data = {"id": response_id, "object": "chat.completion.chunk", "created": int(time.time()), "model": sse_model_name, "choices": [{"index": 0, "delta": {"content": chunk_text}, "finish_reason": None}]}
|
141 |
+
yield f"data: {json.dumps(content_delta_data)}\n\n"
|
142 |
+
if len(content_to_chunk) > chunk_size: await asyncio.sleep(0.05)
|
143 |
+
|
144 |
+
yield create_final_chunk(sse_model_name, response_id)
|
145 |
+
yield "data: [DONE]\n\n"
|
146 |
+
|
147 |
+
except Exception as e:
|
148 |
+
err_msg_detail = f"Error in _base_fake_stream_engine (model: '{sse_model_name}'): {type(e).__name__} - {str(e)}"
|
149 |
+
print(f"ERROR: {err_msg_detail}")
|
150 |
+
sse_err_msg_display = str(e)
|
151 |
+
if len(sse_err_msg_display) > 512: sse_err_msg_display = sse_err_msg_display[:512] + "..."
|
152 |
+
err_resp_for_sse = create_openai_error_response(500, sse_err_msg_display, "server_error")
|
153 |
+
json_payload_for_fake_stream_error = json.dumps(err_resp_for_sse)
|
154 |
+
if not is_auto_attempt:
|
155 |
+
yield f"data: {json_payload_for_fake_stream_error}\n\n"
|
156 |
+
yield "data: [DONE]\n\n"
|
157 |
+
raise
|
158 |
+
|
159 |
+
async def gemini_fake_stream_generator(
|
160 |
+
gemini_client_instance: Any,
|
161 |
+
model_for_api_call: str,
|
162 |
+
prompt_for_api_call: Union[types.Content, List[types.Content]],
|
163 |
+
gen_config_for_api_call: Dict[str, Any],
|
164 |
+
request_obj: OpenAIRequest,
|
165 |
+
is_auto_attempt: bool
|
166 |
+
):
|
167 |
+
model_name_for_log = getattr(gemini_client_instance, 'model_name', 'unknown_gemini_model_object')
|
168 |
+
print(f"FAKE STREAMING (Gemini): Prep for '{request_obj.model}' (API model string: '{model_for_api_call}', client obj: '{model_name_for_log}') with reasoning separation.")
|
169 |
+
response_id = f"chatcmpl-{int(time.time())}"
|
170 |
+
|
171 |
+
# 1. Create and await the API call task
|
172 |
+
api_call_task = asyncio.create_task(
|
173 |
+
gemini_client_instance.aio.models.generate_content(
|
174 |
+
model=model_for_api_call,
|
175 |
+
contents=prompt_for_api_call,
|
176 |
+
config=gen_config_for_api_call
|
177 |
+
)
|
178 |
+
)
|
179 |
+
|
180 |
+
# Keep-alive loop while the main API call is in progress
|
181 |
+
outer_keep_alive_interval = app_config.FAKE_STREAMING_INTERVAL_SECONDS
|
182 |
+
if outer_keep_alive_interval > 0:
|
183 |
+
while not api_call_task.done():
|
184 |
+
keep_alive_data = {"id": "chatcmpl-keepalive", "object": "chat.completion.chunk", "created": int(time.time()), "model": request_obj.model, "choices": [{"delta": {"reasoning_content": ""}, "index": 0, "finish_reason": None}]}
|
185 |
+
yield f"data: {json.dumps(keep_alive_data)}\n\n"
|
186 |
+
await asyncio.sleep(outer_keep_alive_interval)
|
187 |
+
|
188 |
+
try:
|
189 |
+
raw_response = await api_call_task # Get the full Gemini response
|
190 |
+
|
191 |
+
# 2. Parse the response for reasoning and content using the centralized parser
|
192 |
+
separated_reasoning_text = ""
|
193 |
+
separated_actual_content_text = ""
|
194 |
+
if hasattr(raw_response, 'candidates') and raw_response.candidates:
|
195 |
+
# Typically, fake streaming would focus on the first candidate
|
196 |
+
separated_reasoning_text, separated_actual_content_text = parse_gemini_response_for_reasoning_and_content(raw_response.candidates[0])
|
197 |
+
elif hasattr(raw_response, 'text') and raw_response.text is not None: # Fallback for simpler response structures
|
198 |
+
separated_actual_content_text = raw_response.text
|
199 |
+
|
200 |
+
# 3. Define a text processing function (e.g., for deobfuscation)
|
201 |
+
def _process_gemini_text_if_needed(text: str, model_name: str) -> str:
|
202 |
+
if model_name.endswith("-encrypt-full"):
|
203 |
+
return deobfuscate_text(text)
|
204 |
+
return text
|
205 |
+
|
206 |
+
final_reasoning_text = _process_gemini_text_if_needed(separated_reasoning_text, request_obj.model)
|
207 |
+
final_actual_content_text = _process_gemini_text_if_needed(separated_actual_content_text, request_obj.model)
|
208 |
+
|
209 |
+
# Define block checking for the raw response
|
210 |
+
def _check_gemini_block_wrapper(response_to_check: Any):
|
211 |
+
if hasattr(response_to_check, 'prompt_feedback') and hasattr(response_to_check.prompt_feedback, 'block_reason') and response_to_check.prompt_feedback.block_reason:
|
212 |
+
block_message = f"Response blocked by Gemini safety filter: {response_to_check.prompt_feedback.block_reason}"
|
213 |
+
if hasattr(response_to_check.prompt_feedback, 'block_reason_message') and response_to_check.prompt_feedback.block_reason_message:
|
214 |
+
block_message += f" (Message: {response_to_check.prompt_feedback.block_reason_message})"
|
215 |
+
raise ValueError(block_message)
|
216 |
+
|
217 |
+
# Call _base_fake_stream_engine with pre-split and processed texts
|
218 |
+
async for chunk in _base_fake_stream_engine(
|
219 |
+
api_call_task_creator=lambda: asyncio.create_task(asyncio.sleep(0, result=raw_response)), # Dummy task
|
220 |
+
extract_text_from_response_func=lambda r: "", # Not directly used as text is pre-split
|
221 |
+
is_valid_response_func=is_response_valid, # Validates raw_response
|
222 |
+
check_block_reason_func=_check_gemini_block_wrapper, # Checks raw_response
|
223 |
+
process_text_func=None, # Text processing already done above
|
224 |
+
response_id=response_id,
|
225 |
+
sse_model_name=request_obj.model,
|
226 |
+
keep_alive_interval_seconds=0, # Keep-alive for this inner call is 0
|
227 |
+
is_auto_attempt=is_auto_attempt,
|
228 |
+
reasoning_text_to_yield=final_reasoning_text,
|
229 |
+
actual_content_text_to_yield=final_actual_content_text
|
230 |
+
):
|
231 |
+
yield chunk
|
232 |
+
|
233 |
+
except Exception as e_outer_gemini:
|
234 |
+
err_msg_detail = f"Error in gemini_fake_stream_generator (model: '{request_obj.model}'): {type(e_outer_gemini).__name__} - {str(e_outer_gemini)}"
|
235 |
+
print(f"ERROR: {err_msg_detail}")
|
236 |
+
sse_err_msg_display = str(e_outer_gemini)
|
237 |
+
if len(sse_err_msg_display) > 512: sse_err_msg_display = sse_err_msg_display[:512] + "..."
|
238 |
+
err_resp_sse = create_openai_error_response(500, sse_err_msg_display, "server_error")
|
239 |
+
json_payload_error = json.dumps(err_resp_sse)
|
240 |
+
if not is_auto_attempt:
|
241 |
+
yield f"data: {json_payload_error}\n\n"
|
242 |
+
yield "data: [DONE]\n\n"
|
243 |
+
|
244 |
+
async def execute_gemini_call(
|
245 |
+
current_client: Any,
|
246 |
+
model_to_call: str,
|
247 |
+
prompt_func: Callable[[List[OpenAIMessage]], Union[types.Content, List[types.Content]]],
|
248 |
+
gen_config_for_call: Dict[str, Any],
|
249 |
+
request_obj: OpenAIRequest,
|
250 |
+
is_auto_attempt: bool = False
|
251 |
+
):
|
252 |
+
actual_prompt_for_call = prompt_func(request_obj.messages)
|
253 |
+
client_model_name_for_log = getattr(current_client, 'model_name', 'unknown_direct_client_object')
|
254 |
+
print(f"INFO: execute_gemini_call for requested API model '{model_to_call}', using client object with internal name '{client_model_name_for_log}'. Original request model: '{request_obj.model}'")
|
255 |
+
|
256 |
+
# 每次调用时直接从settings获取最新的FAKE_STREAMING值
|
257 |
+
fake_streaming_enabled = False
|
258 |
+
if hasattr(settings, 'FAKE_STREAMING'):
|
259 |
+
fake_streaming_enabled = settings.FAKE_STREAMING
|
260 |
+
else:
|
261 |
+
fake_streaming_enabled = app_config.FAKE_STREAMING_ENABLED
|
262 |
+
|
263 |
+
print(f"DEBUG: FAKE_STREAMING setting is {fake_streaming_enabled} for model {request_obj.model}")
|
264 |
+
|
265 |
+
if request_obj.stream:
|
266 |
+
if fake_streaming_enabled:
|
267 |
+
return StreamingResponse(
|
268 |
+
gemini_fake_stream_generator(
|
269 |
+
current_client,
|
270 |
+
model_to_call,
|
271 |
+
actual_prompt_for_call,
|
272 |
+
gen_config_for_call,
|
273 |
+
request_obj,
|
274 |
+
is_auto_attempt
|
275 |
+
),
|
276 |
+
media_type="text/event-stream"
|
277 |
+
)
|
278 |
+
|
279 |
+
response_id_for_stream = f"chatcmpl-{int(time.time())}"
|
280 |
+
cand_count_stream = request_obj.n or 1
|
281 |
+
|
282 |
+
async def _gemini_real_stream_generator_inner():
|
283 |
+
try:
|
284 |
+
async for chunk_item_call in await current_client.aio.models.generate_content_stream(
|
285 |
+
model=model_to_call,
|
286 |
+
contents=actual_prompt_for_call,
|
287 |
+
config=gen_config_for_call
|
288 |
+
):
|
289 |
+
yield convert_chunk_to_openai(chunk_item_call, request_obj.model, response_id_for_stream, 0)
|
290 |
+
yield create_final_chunk(request_obj.model, response_id_for_stream, cand_count_stream)
|
291 |
+
yield "data: [DONE]\n\n"
|
292 |
+
except Exception as e_stream_call:
|
293 |
+
err_msg_detail_stream = f"Streaming Error (Gemini API, model string: '{model_to_call}'): {type(e_stream_call).__name__} - {str(e_stream_call)}"
|
294 |
+
print(f"ERROR: {err_msg_detail_stream}")
|
295 |
+
s_err = str(e_stream_call); s_err = s_err[:1024]+"..." if len(s_err)>1024 else s_err
|
296 |
+
err_resp = create_openai_error_response(500,s_err,"server_error")
|
297 |
+
j_err = json.dumps(err_resp)
|
298 |
+
if not is_auto_attempt:
|
299 |
+
yield f"data: {j_err}\n\n"
|
300 |
+
yield "data: [DONE]\n\n"
|
301 |
+
raise e_stream_call
|
302 |
+
return StreamingResponse(_gemini_real_stream_generator_inner(), media_type="text/event-stream")
|
303 |
+
else:
|
304 |
+
response_obj_call = await current_client.aio.models.generate_content(
|
305 |
+
model=model_to_call,
|
306 |
+
contents=actual_prompt_for_call,
|
307 |
+
config=gen_config_for_call
|
308 |
+
)
|
309 |
+
if hasattr(response_obj_call, 'prompt_feedback') and hasattr(response_obj_call.prompt_feedback, 'block_reason') and response_obj_call.prompt_feedback.block_reason:
|
310 |
+
block_msg = f"Blocked (Gemini): {response_obj_call.prompt_feedback.block_reason}"
|
311 |
+
if hasattr(response_obj_call.prompt_feedback,'block_reason_message') and response_obj_call.prompt_feedback.block_reason_message:
|
312 |
+
block_msg+=f" ({response_obj_call.prompt_feedback.block_reason_message})"
|
313 |
+
raise ValueError(block_msg)
|
314 |
+
|
315 |
+
if not is_response_valid(response_obj_call):
|
316 |
+
raise ValueError(f"Invalid non-streaming Gemini response for model string '{model_to_call}'. Resp: {str(response_obj_call)[:200]}")
|
317 |
+
return JSONResponse(content=convert_to_openai_format(response_obj_call, request_obj.model))
|
app/vertex/auth.py
ADDED
@@ -0,0 +1,109 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from fastapi import HTTPException, Header, Depends
|
2 |
+
from fastapi.security import APIKeyHeader
|
3 |
+
from typing import Optional
|
4 |
+
from app.config import settings
|
5 |
+
import app.vertex.config as config
|
6 |
+
import os
|
7 |
+
import json
|
8 |
+
from app.utils.logging import vertex_log
|
9 |
+
|
10 |
+
# API Key security scheme
|
11 |
+
api_key_header = APIKeyHeader(name="Authorization", auto_error=False)
|
12 |
+
|
13 |
+
# Function to validate API key
|
14 |
+
def validate_api_key(api_key_to_validate: str) -> bool:
|
15 |
+
|
16 |
+
return True
|
17 |
+
|
18 |
+
# Dependency for API key validation
|
19 |
+
async def get_api_key(authorization: Optional[str] = Header(None)):
|
20 |
+
if authorization is None:
|
21 |
+
raise HTTPException(
|
22 |
+
status_code=401,
|
23 |
+
detail="Missing API key. Please include 'Authorization: Bearer YOUR_API_KEY' header."
|
24 |
+
)
|
25 |
+
|
26 |
+
# Check if the header starts with "Bearer "
|
27 |
+
if not authorization.startswith("Bearer "):
|
28 |
+
raise HTTPException(
|
29 |
+
status_code=401,
|
30 |
+
detail="Invalid API key format. Use 'Authorization: Bearer YOUR_API_KEY'"
|
31 |
+
)
|
32 |
+
|
33 |
+
# Extract the API key
|
34 |
+
api_key = authorization.replace("Bearer ", "")
|
35 |
+
|
36 |
+
# Validate the API key
|
37 |
+
if not validate_api_key(api_key): # Call local validate_api_key
|
38 |
+
raise HTTPException(
|
39 |
+
status_code=401,
|
40 |
+
detail="Invalid API key"
|
41 |
+
)
|
42 |
+
|
43 |
+
return api_key
|
44 |
+
|
45 |
+
def validate_settings():
|
46 |
+
"""Validate settings for Vertex API access."""
|
47 |
+
|
48 |
+
# 检查API key
|
49 |
+
api_key = None
|
50 |
+
if hasattr(settings, 'API_KEY') and settings.API_KEY:
|
51 |
+
api_key = settings.API_KEY
|
52 |
+
else:
|
53 |
+
api_key = config.API_KEY
|
54 |
+
|
55 |
+
if not api_key:
|
56 |
+
vertex_log('warning', "API key is not set. Some functionality may be limited.")
|
57 |
+
|
58 |
+
# 检查Google credentials JSON
|
59 |
+
google_credentials_json = None
|
60 |
+
if hasattr(settings, 'GOOGLE_CREDENTIALS_JSON') and settings.GOOGLE_CREDENTIALS_JSON:
|
61 |
+
google_credentials_json = settings.GOOGLE_CREDENTIALS_JSON
|
62 |
+
else:
|
63 |
+
google_credentials_json = config.GOOGLE_CREDENTIALS_JSON
|
64 |
+
|
65 |
+
if google_credentials_json:
|
66 |
+
try:
|
67 |
+
# 尝试解析JSON确保其有效
|
68 |
+
json.loads(google_credentials_json)
|
69 |
+
vertex_log('info', "Google Credentials JSON is valid")
|
70 |
+
except json.JSONDecodeError:
|
71 |
+
vertex_log('error', "Google Credentials JSON is not valid JSON. Please check the format.")
|
72 |
+
return False
|
73 |
+
|
74 |
+
# 检查project ID
|
75 |
+
project_id = None
|
76 |
+
if hasattr(settings, 'PROJECT_ID') and settings.PROJECT_ID:
|
77 |
+
project_id = settings.PROJECT_ID
|
78 |
+
else:
|
79 |
+
project_id = config.PROJECT_ID
|
80 |
+
|
81 |
+
if not project_id:
|
82 |
+
vertex_log('warning', "Vertex AI Project ID is not set. Required for non-API key methods.")
|
83 |
+
|
84 |
+
# 检查location
|
85 |
+
location = None
|
86 |
+
if hasattr(settings, 'LOCATION') and settings.LOCATION:
|
87 |
+
location = settings.LOCATION
|
88 |
+
else:
|
89 |
+
location = config.LOCATION
|
90 |
+
|
91 |
+
if not location:
|
92 |
+
vertex_log('warning', "Vertex AI Location is not set, using default: us-central1")
|
93 |
+
|
94 |
+
# 验证凭证目录
|
95 |
+
credentials_dir = None
|
96 |
+
if hasattr(settings, 'CREDENTIALS_DIR') and settings.CREDENTIALS_DIR:
|
97 |
+
credentials_dir = settings.CREDENTIALS_DIR
|
98 |
+
else:
|
99 |
+
credentials_dir = config.CREDENTIALS_DIR
|
100 |
+
|
101 |
+
if not os.path.exists(credentials_dir):
|
102 |
+
try:
|
103 |
+
os.makedirs(credentials_dir, exist_ok=True)
|
104 |
+
vertex_log('info', f"Created credentials directory at: {credentials_dir}")
|
105 |
+
except Exception as e:
|
106 |
+
vertex_log('error', f"Failed to create credentials directory: {e}")
|
107 |
+
return False
|
108 |
+
|
109 |
+
return True
|
app/vertex/config.py
ADDED
@@ -0,0 +1,139 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import os
|
2 |
+
import pathlib
|
3 |
+
from app.config import settings
|
4 |
+
from app.utils.logging import vertex_log
|
5 |
+
|
6 |
+
# 确保设置中存在所需的配置项,如果不存在则使用默认值
|
7 |
+
if not hasattr(settings, 'CREDENTIALS_DIR'):
|
8 |
+
# 设置默认目录为storage_dir下的credentials
|
9 |
+
settings.CREDENTIALS_DIR = os.path.join(settings.STORAGE_DIR, "credentials")
|
10 |
+
|
11 |
+
# 使用settings中的配置,保持原有变量名
|
12 |
+
CREDENTIALS_DIR = settings.CREDENTIALS_DIR
|
13 |
+
vertex_log('info', f"Using credentials directory: {CREDENTIALS_DIR}")
|
14 |
+
|
15 |
+
# API Key 配置
|
16 |
+
API_KEY = settings.PASSWORD if hasattr(settings, 'PASSWORD') else ""
|
17 |
+
if API_KEY:
|
18 |
+
vertex_log('info', "Using API Key authentication")
|
19 |
+
else:
|
20 |
+
vertex_log('info', "No API Key found, falling back to credentials file")
|
21 |
+
|
22 |
+
# Google Credentials JSON
|
23 |
+
GOOGLE_CREDENTIALS_JSON = settings.GOOGLE_CREDENTIALS_JSON if hasattr(settings, 'GOOGLE_CREDENTIALS_JSON') else ""
|
24 |
+
if GOOGLE_CREDENTIALS_JSON:
|
25 |
+
vertex_log('info', "Using GOOGLE_CREDENTIALS_JSON environment variable for authentication")
|
26 |
+
|
27 |
+
# 项目和位置配置
|
28 |
+
PROJECT_ID = os.environ.get('VERTEX_PROJECT_ID', '')
|
29 |
+
LOCATION = os.environ.get('VERTEX_LOCATION', 'us-central1')
|
30 |
+
|
31 |
+
# 模型配置URL
|
32 |
+
default_models_config_url = "https://raw.githubusercontent.com/gzzhongqi/vertex2openai/refs/heads/main/vertexModels.json"
|
33 |
+
MODELS_CONFIG_URL = os.environ.get('VERTEX_MODELS_CONFIG_URL', default_models_config_url)
|
34 |
+
vertex_log('info', f"Using models config URL: {MODELS_CONFIG_URL}")
|
35 |
+
|
36 |
+
# Vertex Express API Key 配置
|
37 |
+
VERTEX_EXPRESS_API_KEY_VAL = []
|
38 |
+
if hasattr(settings, 'VERTEX_EXPRESS_API_KEY') and settings.VERTEX_EXPRESS_API_KEY:
|
39 |
+
VERTEX_EXPRESS_API_KEY_VAL = [key.strip() for key in settings.VERTEX_EXPRESS_API_KEY.split(',') if key.strip()]
|
40 |
+
if VERTEX_EXPRESS_API_KEY_VAL:
|
41 |
+
vertex_log('info', f"Loaded {len(VERTEX_EXPRESS_API_KEY_VAL)} Vertex Express API keys from settings")
|
42 |
+
|
43 |
+
# 假流式响应配置
|
44 |
+
FAKE_STREAMING_ENABLED = settings.FAKE_STREAMING if hasattr(settings, 'FAKE_STREAMING') else False
|
45 |
+
FAKE_STREAMING_INTERVAL_SECONDS = settings.FAKE_STREAMING_INTERVAL if hasattr(settings, 'FAKE_STREAMING_INTERVAL') else 1.0
|
46 |
+
FAKE_STREAMING_CHUNK_SIZE = settings.FAKE_STREAMING_CHUNK_SIZE if hasattr(settings, 'FAKE_STREAMING_CHUNK_SIZE') else 10
|
47 |
+
FAKE_STREAMING_DELAY_PER_CHUNK = settings.FAKE_STREAMING_DELAY_PER_CHUNK if hasattr(settings, 'FAKE_STREAMING_DELAY_PER_CHUNK') else 0.1
|
48 |
+
vertex_log('info', f"Fake streaming is {'enabled' if FAKE_STREAMING_ENABLED else 'disabled'} with interval {FAKE_STREAMING_INTERVAL_SECONDS} seconds, chunk size {FAKE_STREAMING_CHUNK_SIZE}, delay per chunk {FAKE_STREAMING_DELAY_PER_CHUNK} seconds")
|
49 |
+
|
50 |
+
def update_env_var(name, value):
|
51 |
+
"""Update environment variable in memory."""
|
52 |
+
os.environ[name] = value
|
53 |
+
vertex_log('info', f"Updated environment variable: {name}")
|
54 |
+
|
55 |
+
def reload_config():
|
56 |
+
"""重新加载配置,通常在持久化设置加载后调用"""
|
57 |
+
global GOOGLE_CREDENTIALS_JSON, VERTEX_EXPRESS_API_KEY_VAL, API_KEY
|
58 |
+
|
59 |
+
# 重新加载Google Credentials JSON
|
60 |
+
GOOGLE_CREDENTIALS_JSON = settings.GOOGLE_CREDENTIALS_JSON if hasattr(settings, 'GOOGLE_CREDENTIALS_JSON') else ""
|
61 |
+
if GOOGLE_CREDENTIALS_JSON:
|
62 |
+
vertex_log('info', "重新加载了GOOGLE_CREDENTIALS_JSON配置")
|
63 |
+
|
64 |
+
# 重新加载Vertex Express API Key
|
65 |
+
VERTEX_EXPRESS_API_KEY_VAL = []
|
66 |
+
if hasattr(settings, 'VERTEX_EXPRESS_API_KEY') and settings.VERTEX_EXPRESS_API_KEY:
|
67 |
+
VERTEX_EXPRESS_API_KEY_VAL = [key.strip() for key in settings.VERTEX_EXPRESS_API_KEY.split(',') if key.strip()]
|
68 |
+
if VERTEX_EXPRESS_API_KEY_VAL:
|
69 |
+
vertex_log('info', f"重新加载了{len(VERTEX_EXPRESS_API_KEY_VAL)}个Vertex Express API keys")
|
70 |
+
|
71 |
+
# 重新加载API Key
|
72 |
+
API_KEY = settings.PASSWORD if hasattr(settings, 'PASSWORD') else ""
|
73 |
+
if API_KEY:
|
74 |
+
vertex_log('info', "重新加载了API Key配置")
|
75 |
+
|
76 |
+
def update_config(name, value):
|
77 |
+
"""Update config variables in settings and environment variables."""
|
78 |
+
if name == 'VERTEX_API_KEY':
|
79 |
+
settings.PASSWORD = value # 更新settings中的值
|
80 |
+
global API_KEY
|
81 |
+
API_KEY = value # 更新本地变量
|
82 |
+
vertex_log('info', "Updated API Key")
|
83 |
+
elif name == 'GOOGLE_CREDENTIALS_JSON':
|
84 |
+
settings.GOOGLE_CREDENTIALS_JSON = value
|
85 |
+
global GOOGLE_CREDENTIALS_JSON
|
86 |
+
GOOGLE_CREDENTIALS_JSON = value
|
87 |
+
vertex_log('info', "Updated Google Credentials JSON")
|
88 |
+
elif name == 'VERTEX_PROJECT_ID':
|
89 |
+
os.environ['VERTEX_PROJECT_ID'] = value # 这个值只在环境变量中
|
90 |
+
global PROJECT_ID
|
91 |
+
PROJECT_ID = value
|
92 |
+
vertex_log('info', f"Updated Project ID to {value}")
|
93 |
+
elif name == 'VERTEX_LOCATION':
|
94 |
+
os.environ['VERTEX_LOCATION'] = value
|
95 |
+
global LOCATION
|
96 |
+
LOCATION = value
|
97 |
+
vertex_log('info', f"Updated Location to {value}")
|
98 |
+
elif name == 'VERTEX_MODELS_CONFIG_URL':
|
99 |
+
os.environ['VERTEX_MODELS_CONFIG_URL'] = value
|
100 |
+
global MODELS_CONFIG_URL
|
101 |
+
MODELS_CONFIG_URL = value
|
102 |
+
vertex_log('info', f"Updated Models Config URL to {value}")
|
103 |
+
elif name == 'VERTEX_EXPRESS_API_KEY':
|
104 |
+
settings.VERTEX_EXPRESS_API_KEY = value
|
105 |
+
global VERTEX_EXPRESS_API_KEY_VAL
|
106 |
+
VERTEX_EXPRESS_API_KEY_VAL = [key.strip() for key in value.split(',') if key.strip()]
|
107 |
+
vertex_log('info', f"Updated Vertex Express API Key, now have {len(VERTEX_EXPRESS_API_KEY_VAL)} keys")
|
108 |
+
elif name == 'FAKE_STREAMING':
|
109 |
+
# 更新FAKE_STREAMING配置
|
110 |
+
settings.FAKE_STREAMING = value
|
111 |
+
global FAKE_STREAMING_ENABLED
|
112 |
+
FAKE_STREAMING_ENABLED = value
|
113 |
+
vertex_log('info', f"Updated FAKE_STREAMING to {value}")
|
114 |
+
# 确保环境变量也被更新
|
115 |
+
os.environ['FAKE_STREAMING'] = str(value).lower()
|
116 |
+
elif name == 'FAKE_STREAMING_INTERVAL':
|
117 |
+
# 更新FAKE_STREAMING_INTERVAL配置
|
118 |
+
settings.FAKE_STREAMING_INTERVAL = value
|
119 |
+
global FAKE_STREAMING_INTERVAL_SECONDS
|
120 |
+
FAKE_STREAMING_INTERVAL_SECONDS = value
|
121 |
+
vertex_log('info', f"Updated FAKE_STREAMING_INTERVAL to {value}")
|
122 |
+
elif name == 'FAKE_STREAMING_CHUNK_SIZE':
|
123 |
+
settings.FAKE_STREAMING_CHUNK_SIZE = value
|
124 |
+
global FAKE_STREAMING_CHUNK_SIZE
|
125 |
+
FAKE_STREAMING_CHUNK_SIZE = value
|
126 |
+
vertex_log('info', f"Updated FAKE_STREAMING_CHUNK_SIZE to {value}")
|
127 |
+
elif name == 'FAKE_STREAMING_DELAY_PER_CHUNK':
|
128 |
+
settings.FAKE_STREAMING_DELAY_PER_CHUNK = value
|
129 |
+
global FAKE_STREAMING_DELAY_PER_CHUNK
|
130 |
+
FAKE_STREAMING_DELAY_PER_CHUNK = value
|
131 |
+
vertex_log('info', f"Updated FAKE_STREAMING_DELAY_PER_CHUNK to {value}")
|
132 |
+
else:
|
133 |
+
vertex_log('warning', f"Unknown config variable: {name}")
|
134 |
+
return
|
135 |
+
|
136 |
+
# 更新环境变量
|
137 |
+
update_env_var(name, value)
|
138 |
+
|
139 |
+
# Validation logic moved to app/auth.py
|
app/vertex/credentials_manager.py
ADDED
@@ -0,0 +1,271 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import os
|
2 |
+
import glob
|
3 |
+
import random
|
4 |
+
import json
|
5 |
+
from typing import List, Dict, Any
|
6 |
+
from google.auth.transport.requests import Request as AuthRequest
|
7 |
+
from google.oauth2 import service_account
|
8 |
+
import app.vertex.config as app_config # Changed from relative
|
9 |
+
from app.utils.logging import vertex_log
|
10 |
+
|
11 |
+
# Helper function to parse multiple JSONs from a string
|
12 |
+
def parse_multiple_json_credentials(json_str: str) -> List[Dict[str, Any]]:
|
13 |
+
"""
|
14 |
+
Parse multiple JSON objects from a string separated by commas.
|
15 |
+
Format expected: {json_object1},{json_object2},...
|
16 |
+
Returns a list of parsed JSON objects.
|
17 |
+
"""
|
18 |
+
# 检查输入是否为None或空字符串
|
19 |
+
if not json_str or not json_str.strip():
|
20 |
+
vertex_log('debug', "parse_multiple_json_credentials received empty or None input")
|
21 |
+
return []
|
22 |
+
|
23 |
+
credentials_list = []
|
24 |
+
nesting_level = 0
|
25 |
+
current_object_start = -1
|
26 |
+
str_length = len(json_str)
|
27 |
+
|
28 |
+
for i, char in enumerate(json_str):
|
29 |
+
if char == '{':
|
30 |
+
if nesting_level == 0:
|
31 |
+
current_object_start = i
|
32 |
+
nesting_level += 1
|
33 |
+
elif char == '}':
|
34 |
+
if nesting_level > 0:
|
35 |
+
nesting_level -= 1
|
36 |
+
if nesting_level == 0 and current_object_start != -1:
|
37 |
+
# Found a complete top-level JSON object
|
38 |
+
json_object_str = json_str[current_object_start : i + 1]
|
39 |
+
try:
|
40 |
+
credentials_info = json.loads(json_object_str)
|
41 |
+
# Basic validation for service account structure
|
42 |
+
required_fields = ["type", "project_id", "private_key_id", "private_key", "client_email"]
|
43 |
+
if all(field in credentials_info for field in required_fields):
|
44 |
+
credentials_list.append(credentials_info)
|
45 |
+
vertex_log('debug', "Successfully parsed a JSON credential object.")
|
46 |
+
else:
|
47 |
+
vertex_log('warning', f"Parsed JSON object missing required fields: {json_object_str[:100]}...")
|
48 |
+
except json.JSONDecodeError as e:
|
49 |
+
vertex_log('error', f"Failed to parse JSON object segment: {json_object_str[:100]}... Error: {e}")
|
50 |
+
current_object_start = -1 # Reset for the next object
|
51 |
+
else:
|
52 |
+
# Found a closing brace without a matching open brace in scope, might indicate malformed input
|
53 |
+
vertex_log('warning', f"Encountered unexpected '}}' at index {i}. Input might be malformed.")
|
54 |
+
|
55 |
+
|
56 |
+
if nesting_level != 0:
|
57 |
+
vertex_log('warning', f"JSON string parsing ended with non-zero nesting level ({nesting_level}). Check for unbalanced braces.")
|
58 |
+
|
59 |
+
vertex_log('debug', f"Parsed {len(credentials_list)} credential objects from the input string.")
|
60 |
+
return credentials_list
|
61 |
+
|
62 |
+
def _refresh_auth(credentials):
|
63 |
+
"""Helper function to refresh GCP token."""
|
64 |
+
if not credentials:
|
65 |
+
vertex_log('error', "_refresh_auth called with no credentials.")
|
66 |
+
return None
|
67 |
+
try:
|
68 |
+
# Assuming credentials object has a project_id attribute for logging
|
69 |
+
project_id_for_log = getattr(credentials, 'project_id', 'Unknown')
|
70 |
+
vertex_log('info', f"Attempting to refresh token for project: {project_id_for_log}...")
|
71 |
+
credentials.refresh(AuthRequest())
|
72 |
+
vertex_log('info', f"Token refreshed successfully for project: {project_id_for_log}")
|
73 |
+
return credentials.token
|
74 |
+
except Exception as e:
|
75 |
+
project_id_for_log = getattr(credentials, 'project_id', 'Unknown')
|
76 |
+
vertex_log('error', f"Error refreshing GCP token for project {project_id_for_log}: {e}")
|
77 |
+
return None
|
78 |
+
|
79 |
+
|
80 |
+
# Credential Manager for handling multiple service accounts
|
81 |
+
class CredentialManager:
|
82 |
+
def __init__(self): # default_credentials_dir is now handled by config
|
83 |
+
# Use CREDENTIALS_DIR from config
|
84 |
+
self.credentials_dir = app_config.CREDENTIALS_DIR
|
85 |
+
self.credentials_files = []
|
86 |
+
self.current_index = 0
|
87 |
+
self.credentials = None
|
88 |
+
self.project_id = None
|
89 |
+
# New: Store credentials loaded directly from JSON objects
|
90 |
+
self.in_memory_credentials: List[Dict[str, Any]] = []
|
91 |
+
self.load_credentials_list() # Load file-based credentials initially
|
92 |
+
|
93 |
+
def clear_json_string_credentials(self) -> int:
|
94 |
+
"""
|
95 |
+
清除所有通过JSON字符串加载的内存凭证。
|
96 |
+
返回清除的凭证数量。
|
97 |
+
"""
|
98 |
+
count_before = len(self.in_memory_credentials)
|
99 |
+
# 只保留不是从json字符串加载的凭证
|
100 |
+
self.in_memory_credentials = [cred for cred in self.in_memory_credentials if cred.get('source') != 'json_string']
|
101 |
+
count_after = len(self.in_memory_credentials)
|
102 |
+
removed_count = count_before - count_after
|
103 |
+
vertex_log('debug', f"从CredentialManager中清除了{removed_count}个由JSON字符串加载的凭证")
|
104 |
+
return removed_count
|
105 |
+
|
106 |
+
def add_credential_from_json(self, credentials_info: Dict[str, Any]) -> bool:
|
107 |
+
"""
|
108 |
+
Add a credential from a JSON object to the manager's in-memory list.
|
109 |
+
|
110 |
+
Args:
|
111 |
+
credentials_info: Dict containing service account credentials
|
112 |
+
|
113 |
+
Returns:
|
114 |
+
bool: True if credential was added successfully, False otherwise
|
115 |
+
"""
|
116 |
+
try:
|
117 |
+
# Validate structure again before creating credentials object
|
118 |
+
required_fields = ["type", "project_id", "private_key_id", "private_key", "client_email"]
|
119 |
+
if not all(field in credentials_info for field in required_fields):
|
120 |
+
vertex_log('warning', "Skipping JSON credential due to missing required fields.")
|
121 |
+
return False
|
122 |
+
|
123 |
+
credentials = service_account.Credentials.from_service_account_info(
|
124 |
+
credentials_info,
|
125 |
+
scopes=['https://www.googleapis.com/auth/cloud-platform']
|
126 |
+
)
|
127 |
+
project_id = credentials.project_id
|
128 |
+
vertex_log('debug', f"Successfully created credentials object from JSON for project: {project_id}")
|
129 |
+
|
130 |
+
# Store the credentials object and project ID
|
131 |
+
self.in_memory_credentials.append({
|
132 |
+
'credentials': credentials,
|
133 |
+
'project_id': project_id,
|
134 |
+
'source': 'json_string' # Add source for clarity
|
135 |
+
})
|
136 |
+
vertex_log('info', f"Added credential for project {project_id} from JSON string to Credential Manager.")
|
137 |
+
return True
|
138 |
+
except Exception as e:
|
139 |
+
vertex_log('error', f"Failed to create credentials from parsed JSON object: {e}")
|
140 |
+
return False
|
141 |
+
|
142 |
+
def load_credentials_from_json_list(self, json_list: List[Dict[str, Any]]) -> int:
|
143 |
+
"""
|
144 |
+
Load multiple credentials from a list of JSON objects into memory.
|
145 |
+
|
146 |
+
Args:
|
147 |
+
json_list: List of dicts containing service account credentials
|
148 |
+
|
149 |
+
Returns:
|
150 |
+
int: Number of credentials successfully loaded
|
151 |
+
"""
|
152 |
+
# Avoid duplicates if called multiple times
|
153 |
+
existing_projects = {cred['project_id'] for cred in self.in_memory_credentials}
|
154 |
+
success_count = 0
|
155 |
+
newly_added_projects = set()
|
156 |
+
|
157 |
+
for credentials_info in json_list:
|
158 |
+
project_id = credentials_info.get('project_id')
|
159 |
+
# Check if this project_id from JSON exists in files OR already added from JSON
|
160 |
+
is_duplicate_file = any(os.path.basename(f) == f"{project_id}.json" for f in self.credentials_files) # Basic check
|
161 |
+
is_duplicate_mem = project_id in existing_projects or project_id in newly_added_projects
|
162 |
+
|
163 |
+
if project_id and not is_duplicate_file and not is_duplicate_mem:
|
164 |
+
if self.add_credential_from_json(credentials_info):
|
165 |
+
success_count += 1
|
166 |
+
newly_added_projects.add(project_id)
|
167 |
+
elif project_id:
|
168 |
+
vertex_log('debug', f"Skipping duplicate credential for project {project_id} from JSON list.")
|
169 |
+
|
170 |
+
|
171 |
+
if success_count > 0:
|
172 |
+
vertex_log('info', f"Loaded {success_count} new credentials from JSON list into memory.")
|
173 |
+
return success_count
|
174 |
+
|
175 |
+
def load_credentials_list(self):
|
176 |
+
"""Load the list of available credential files"""
|
177 |
+
# Look for all .json files in the credentials directory
|
178 |
+
pattern = os.path.join(self.credentials_dir, "*.json")
|
179 |
+
self.credentials_files = glob.glob(pattern)
|
180 |
+
|
181 |
+
if not self.credentials_files:
|
182 |
+
# vertex_log('info', f"No credential files found in {self.credentials_dir}")
|
183 |
+
pass # Don't return False yet, might have in-memory creds
|
184 |
+
else:
|
185 |
+
vertex_log('info', f"Found {len(self.credentials_files)} credential files: {[os.path.basename(f) for f in self.credentials_files]}")
|
186 |
+
|
187 |
+
# Check total credentials
|
188 |
+
return self.get_total_credentials() > 0
|
189 |
+
|
190 |
+
def refresh_credentials_list(self):
|
191 |
+
"""Refresh the list of credential files and return if any credentials exist"""
|
192 |
+
old_file_count = len(self.credentials_files)
|
193 |
+
self.load_credentials_list() # Reloads file list
|
194 |
+
new_file_count = len(self.credentials_files)
|
195 |
+
|
196 |
+
if old_file_count != new_file_count:
|
197 |
+
vertex_log('info', f"Credential files updated: {old_file_count} -> {new_file_count}")
|
198 |
+
|
199 |
+
# Total credentials = files + in-memory
|
200 |
+
total_credentials = self.get_total_credentials()
|
201 |
+
vertex_log('debug', f"Refresh check - Total credentials available: {total_credentials}")
|
202 |
+
return total_credentials > 0
|
203 |
+
|
204 |
+
def get_total_credentials(self):
|
205 |
+
"""Returns the total number of credentials (file + in-memory)."""
|
206 |
+
return len(self.credentials_files) + len(self.in_memory_credentials)
|
207 |
+
|
208 |
+
|
209 |
+
def get_random_credentials(self):
|
210 |
+
"""
|
211 |
+
Get a random credential (file or in-memory) and load it.
|
212 |
+
Tries each available credential source at most once in a random order.
|
213 |
+
"""
|
214 |
+
all_sources = []
|
215 |
+
# Add file paths (as type 'file')
|
216 |
+
for file_path in self.credentials_files:
|
217 |
+
all_sources.append({'type': 'file', 'value': file_path})
|
218 |
+
|
219 |
+
# Add in-memory credentials (as type 'memory_object')
|
220 |
+
# Assuming self.in_memory_credentials stores dicts like {'credentials': cred_obj, 'project_id': pid, 'source': 'json_string'}
|
221 |
+
for idx, mem_cred_info in enumerate(self.in_memory_credentials):
|
222 |
+
all_sources.append({'type': 'memory_object', 'value': mem_cred_info, 'original_index': idx})
|
223 |
+
|
224 |
+
if not all_sources:
|
225 |
+
vertex_log('warning', "No credentials available for random selection (no files or in-memory).")
|
226 |
+
return None, None
|
227 |
+
|
228 |
+
random.shuffle(all_sources) # Shuffle to try in a random order
|
229 |
+
|
230 |
+
for source_info in all_sources:
|
231 |
+
source_type = source_info['type']
|
232 |
+
|
233 |
+
if source_type == 'file':
|
234 |
+
file_path = source_info['value']
|
235 |
+
vertex_log('debug', f"Attempting to load credential from file: {os.path.basename(file_path)}")
|
236 |
+
try:
|
237 |
+
credentials = service_account.Credentials.from_service_account_file(
|
238 |
+
file_path,
|
239 |
+
scopes=['https://www.googleapis.com/auth/cloud-platform']
|
240 |
+
)
|
241 |
+
project_id = credentials.project_id
|
242 |
+
vertex_log('info', f"Successfully loaded credential from file {os.path.basename(file_path)} for project: {project_id}")
|
243 |
+
self.credentials = credentials # Cache last successfully loaded
|
244 |
+
self.project_id = project_id
|
245 |
+
return credentials, project_id
|
246 |
+
except Exception as e:
|
247 |
+
vertex_log('error', f"Failed loading credentials file {os.path.basename(file_path)}: {e}. Trying next available source.")
|
248 |
+
continue # Try next source
|
249 |
+
|
250 |
+
elif source_type == 'memory_object':
|
251 |
+
mem_cred_detail = source_info['value']
|
252 |
+
# The 'credentials' object is already a service_account.Credentials instance
|
253 |
+
credentials = mem_cred_detail.get('credentials')
|
254 |
+
project_id = mem_cred_detail.get('project_id')
|
255 |
+
|
256 |
+
if credentials and project_id:
|
257 |
+
vertex_log('info', f"Using in-memory credential for project: {project_id} (Source: {mem_cred_detail.get('source', 'unknown')})")
|
258 |
+
# Here, we might want to ensure the credential object is still valid if it can expire
|
259 |
+
# For service_account.Credentials from_service_account_info, they typically don't self-refresh
|
260 |
+
# in the same way as ADC, but are long-lived based on the private key.
|
261 |
+
# If validation/refresh were needed, it would be complex here.
|
262 |
+
# For now, assume it's usable if present.
|
263 |
+
self.credentials = credentials # Cache last successfully loaded/used
|
264 |
+
self.project_id = project_id
|
265 |
+
return credentials, project_id
|
266 |
+
else:
|
267 |
+
vertex_log('warning', f"In-memory credential entry missing 'credentials' or 'project_id' at original index {source_info.get('original_index', 'N/A')}. Skipping.")
|
268 |
+
continue # Try next source
|
269 |
+
|
270 |
+
vertex_log('warning', "All available credential sources failed to load.")
|
271 |
+
return None, None
|